From 5b607a5eabf47c7cfc11ffe12d7edd1c4a1fb08a Mon Sep 17 00:00:00 2001 From: denispetre Date: Fri, 3 Jul 2026 10:59:30 +0300 Subject: [PATCH 1/4] feat(apollo-react): add ModelPicker Material component Ports the LLM ModelPicker into the Material component set as ap-model-picker, unchanged in design and behavior: - controlled picker over LLM Gateway Discovery models with search, Category/Provider grouping (BYO always first), lifecycle chips, folder switcher, and a Use custom model footer CTA - per-product customization via props: filter, friendlyNameFor, customTagsFor badges, render slots - platform-aware hooks: BYO management gated on the AiTrustLayerByoLlm entitlement (fail closed) and Orchestrator folder fetching - WAI-ARIA listbox pattern with aria-activedescendant keyboard nav - CSS-variable theming with apollo-core token fallbacks, no ThemeProvider required - memoized rows + virtualization above 120 options (new dependency: @tanstack/react-virtual) Repo adaptations only: useSafeLingui instead of raw Lingui hooks (new PickerTranslator contract), vitest test suite (52 tests), biome formatting, a lingui catalog entry, and React 19 ref typings. Co-Authored-By: Claude Fable 5 --- packages/apollo-react/lingui.config.ts | 4 + packages/apollo-react/package.json | 1 + .../ap-model-picker/ModelPicker.stories.tsx | 1226 +++++++++++++++++ .../ap-model-picker/ModelPicker.test.tsx | 152 ++ .../ap-model-picker/ModelPicker.tsx | 901 ++++++++++++ .../ap-model-picker/ModelTagChip.tsx | 138 ++ .../components/ap-model-picker/README.md | 408 ++++++ .../components/ap-model-picker/i18n.ts | 198 +++ .../components/ap-model-picker/index.ts | 79 ++ .../ap-model-picker/locales/de.json | 40 + .../ap-model-picker/locales/en.json | 40 + .../ap-model-picker/locales/es-MX.json | 40 + .../ap-model-picker/locales/es.json | 40 + .../ap-model-picker/locales/fr.json | 40 + .../ap-model-picker/locales/ja.json | 40 + .../ap-model-picker/locales/ko.json | 40 + .../ap-model-picker/locales/pt-BR.json | 40 + .../ap-model-picker/locales/pt.json | 40 + .../ap-model-picker/locales/ru.json | 40 + .../ap-model-picker/locales/tr.json | 40 + .../ap-model-picker/locales/zh-CN.json | 40 + .../ap-model-picker/locales/zh-TW.json | 40 + .../primitives/FolderSwitcher.test.tsx | 64 + .../primitives/FolderSwitcher.tsx | 190 +++ .../primitives/GroupHeader.tsx | 180 +++ .../primitives/ModelOptionRow.tsx | 379 +++++ .../primitives/OptionList.test.tsx | 167 +++ .../ap-model-picker/primitives/OptionList.tsx | 400 ++++++ .../primitives/PickerPopup.tsx | 98 ++ .../primitives/PickerSearchInput.tsx | 138 ++ .../primitives/PickerTrigger.tsx | 238 ++++ .../components/ap-model-picker/types.ts | 190 +++ .../ap-model-picker/useDiscoveryModels.ts | 105 ++ .../ap-model-picker/useModelPickerState.ts | 290 ++++ .../usePlatformAccess.test.tsx | 181 +++ .../ap-model-picker/usePlatformAccess.ts | 233 ++++ .../components/ap-model-picker/utils.test.ts | 336 +++++ .../components/ap-model-picker/utils.ts | 484 +++++++ .../src/material/components/index.ts | 1 + pnpm-lock.yaml | 36 +- 40 files changed, 7329 insertions(+), 8 deletions(-) create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.stories.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/README.md create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/i18n.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/index.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/de.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/en.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/es-MX.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/es.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/fr.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/ja.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/ko.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/pt-BR.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/pt.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/ru.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/tr.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/zh-CN.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/locales/zh-TW.json create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/ModelOptionRow.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerPopup.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerSearchInput.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerTrigger.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/types.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/useDiscoveryModels.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/utils.test.ts create mode 100644 packages/apollo-react/src/material/components/ap-model-picker/utils.ts diff --git a/packages/apollo-react/lingui.config.ts b/packages/apollo-react/lingui.config.ts index d1b6f80d9..f6c78777c 100644 --- a/packages/apollo-react/lingui.config.ts +++ b/packages/apollo-react/lingui.config.ts @@ -17,6 +17,10 @@ const config: LinguiConfig = { path: 'src/material/components/ap-rich-text-editor/locales/{locale}', include: ['src/material/components/ap-rich-text-editor'], }, + { + path: 'src/material/components/ap-model-picker/locales/{locale}', + include: ['src/material/components/ap-model-picker'], + }, { path: 'src/canvas/locales/{locale}', include: ['src/canvas'], diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 9adeb97a0..81463b785 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -192,6 +192,7 @@ "@mui/system": "^5.18.0", "@mui/x-date-pickers": "^6.20.2", "@mui/x-tree-view": "^8.21.0", + "@tanstack/react-virtual": "^3.14.3", "@tiptap/core": "^3.19.0", "@tiptap/extension-document": "^3.19.0", "@tiptap/extension-hard-break": "^3.19.0", diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.stories.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.stories.tsx new file mode 100644 index 000000000..d010b143a --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.stories.tsx @@ -0,0 +1,1226 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type React from 'react'; +import { useMemo, useState } from 'react'; + +import { ModelPicker } from './ModelPicker'; +import type { DiscoveryModel } from './types'; +import { defaultCostTier } from './utils'; + +const MOCK_MODELS: DiscoveryModel[] = [ + { + modelId: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + modelName: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + vendor: 'AnthropicClaude', + modelFamily: 'Claude4', + modelSubscriptionType: 'UiPathOwned', + isPreview: true, + routingDetails: { + geography: 'GLOBAL', + model: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + }, + modelDetails: { + contextWindowTokens: 200000, + maxOutputTokens: 8192, + // Sonnet 4.6: premium tier in defaultCostTier ($3/M input → standard + // would be wrong; we bump it via the test rate to demonstrate the + // boundary). Real gateway rates here. + costDetails: { + inputTokenCost: 3.0, + outputTokenCost: 15.0, + currency: 'USD', + }, + }, + }, + { + modelId: 'anthropic.claude-sonnet-4-5-20250929-v1:0', + modelName: 'anthropic.claude-sonnet-4-5-20250929-v1:0', + vendor: 'AnthropicClaude', + modelSubscriptionType: 'UiPathOwned', + routingDetails: { geography: 'GLOBAL' }, + modelDetails: { + contextWindowTokens: 200000, + costDetails: { + inputTokenCost: 3.0, + outputTokenCost: 15.0, + currency: 'USD', + }, + }, + }, + { + modelId: 'gemini-3-flash-preview-20260215', + modelName: 'gemini-3-flash-preview-20260215', + vendor: 'VertexAi', + modelSubscriptionType: 'UiPathOwned', + isPreview: true, + routingDetails: { geography: 'US' }, + modelDetails: { + contextWindowTokens: 1000000, + costDetails: { + inputTokenCost: 0.35, + outputTokenCost: 1.5, + currency: 'USD', + }, + }, + }, + { + modelId: 'gpt-5-2025-08-07', + modelName: 'gpt-5-2025-08-07', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + isPreview: true, + routingDetails: { geography: 'GLOBAL' }, + modelDetails: { + contextWindowTokens: 400000, + maxOutputTokens: 128000, + // gpt-5 large: premium + costDetails: { + inputTokenCost: 6.0, + outputTokenCost: 18.0, + currency: 'USD', + }, + }, + }, + { + modelId: 'gpt-4o-2024-08-06', + modelName: 'gpt-4o-2024-08-06', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + routingDetails: { geography: 'EU' }, + deprecationDetails: { + usageEndDate: '2026-09-01', + replacedBy: 'gpt-5-2025-08-07', + }, + modelDetails: { + contextWindowTokens: 128000, + maxOutputTokens: 16384, + costDetails: { + inputTokenCost: 2.5, + outputTokenCost: 10.0, + currency: 'USD', + }, + }, + }, + { + modelId: 'byo-cigna-gpt-4o-2024-08-06', + modelName: 'gpt-4o-2024-08-06', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMReplacedLikeForLike', + byoConnectionLabel: 'CignaSandboxOkta 1', + byomDetails: { + integrationServiceConnectionId: 'b8eb36d1-ca1f-4a90-9b14-795e8acd7ec9', + availableOperationCodes: ['agents-design-eval-deploy'], + }, + }, + { + modelId: 'byo-cigna-gpt-4o-mini', + modelName: 'gpt-4o-mini-2024-07-18', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMReplacedLikeForLike', + byoConnectionLabel: 'CignaSandboxOkta 1', + }, + { + modelId: 'byo-vlad-gemini-2-0-flash', + modelName: 'my-gemini-2.0-flash-001', + vendor: 'VertexAi', + modelSubscriptionType: 'BYOMReplacedAlternative', + byoConnectionLabel: "Vlad's Vertex with Anthropic", + }, + { + modelId: 'shared-andreis-gemini-flash', + modelName: 'andreis-gemini-flash', + vendor: 'VertexAi', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'Google Vertex #2', + }, + { + modelId: 'shared-haiku-4-5', + modelName: 'anthropic.claude-haiku-4-5-20251001-v1:0', + vendor: 'AwsBedrock', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'Amazon Bedrock', + routingDetails: { geography: 'US' }, + }, + { + modelId: 'shared-sonnet-4-5', + modelName: 'anthropic.claude-sonnet-4-5-20250929-v1:0', + vendor: 'AwsBedrock', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'Amazon Bedrock', + routingDetails: { geography: 'US' }, + }, + { + modelId: 'shared-coe-llama-70b', + modelName: 'coe-llama-70b', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'fireworks #2', + }, + { + modelId: 'shared-gemini-25-flash', + modelName: 'gemini-2.5-flash', + vendor: 'VertexAi', + modelSubscriptionType: 'BYOMAdded', + isPreview: true, + byoConnectionLabel: 'Google Vertex #2', + }, + { + modelId: 'shared-gemini-25-pro', + modelName: 'gemini-2.5-pro', + vendor: 'VertexAi', + modelSubscriptionType: 'BYOMAdded', + isPreview: true, + byoConnectionLabel: 'Google Vertex #2', + }, + { + modelId: 'shared-llama-33-irs-1', + modelName: 'llama-3.3-70b-i', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'IRS Llama 3.3 Test', + }, + { + modelId: 'uipath-gpt-41-mini', + modelName: 'gpt-4.1-mini-2025-04-14', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + routingDetails: { geography: 'EU' }, + modelDetails: { + contextWindowTokens: 1000000, + maxOutputTokens: 32768, + // gpt-4.1-mini: basic + costDetails: { + inputTokenCost: 0.4, + outputTokenCost: 1.6, + currency: 'USD', + }, + }, + }, + // A retired model whose traffic is being substituted by the gateway. + // `effectiveModel` is what the gateway is actually routing to — + // different from `modelName`, which is what the user picked. Triggers + // the `Routes to …` tag chip on rows and on the trigger. + { + modelId: 'uipath-gpt-5-2025-08-07', + modelName: 'gpt-5-2025-08-07', + effectiveModel: 'gpt-6-2026-03-15', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + deprecationDetails: { + usageEndDate: '2026-05-01', + replacedBy: 'gpt-6-2026-03-15', + }, + modelDetails: { + contextWindowTokens: 400000, + maxOutputTokens: 128000, + costDetails: { + inputTokenCost: 2.5, + outputTokenCost: 10.0, + currency: 'USD', + }, + }, + }, +]; + +const meta: Meta = { + title: 'Components/ModelPicker', + component: ModelPicker, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Picker for LLM Gateway Discovery API models. Two visual POCs ' + + 'share the same controlled API; switch via the `variant` arg. ' + + 'Tags (Recommended/Preview/Custom/Deprecating/Out-of-region) ' + + 'are derived locally from the Discovery DTO: see `deriveModelTags`.', + }, + }, + }, + argTypes: { + variant: { + control: { type: 'radio' }, + options: ['searchable', 'virtualized'], + }, + groupBy: { + control: { type: 'radio' }, + options: ['subscription', 'vendor', 'flat'], + }, + homeRegion: { + control: { type: 'select' }, + options: ['EU', 'US', 'CA', 'UK', 'JA', 'IN', 'GLOBAL'], + }, + onChange: { action: 'onChange' }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const Controlled = (args: React.ComponentProps) => { + const [value, setValue] = useState( + args.value ?? 'anthropic.claude-sonnet-4-6-20260301-v1:0' + ); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + /> +
+
+ ); +}; + +export const Default: Story = { + name: 'Default', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, +}; + +export const Virtualized: Story = { + name: 'Virtualized (500+ models)', + render: Controlled, + args: { + variant: 'virtualized', + models: MOCK_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, +}; + +export const OutOfRegionWarnings: Story = { + name: 'Out-of-region warnings (homeRegion=EU)', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + homeRegion: 'EU', + value: 'shared-haiku-4-5', + }, +}; + +export const Loading: Story = { + render: Controlled, + args: { + variant: 'searchable', + models: [], + loading: true, + }, +}; + +export const ErrorState: Story = { + name: 'Error state', + render: Controlled, + args: { + variant: 'searchable', + models: [], + error: new Error('Discovery API 403 Forbidden'), + }, +}; + +export const Empty: Story = { + render: Controlled, + args: { + variant: 'searchable', + models: [], + }, +}; + +export const AdminCanManageByo: Story = { + name: 'Admin: can manage BYO', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + homeRegion: 'EU', + // Admin viewer: default edit/delete icons render on BYO rows, and + // the "Use custom model" footer CTA appears at the bottom of the + // popup. The footer click is wired by `onUseCustomModel`. + canManageByo: true, + onUseCustomModel: () => { + // eslint-disable-next-line no-console + console.log('[story] open BYO wizard'); + }, + }, + parameters: { + docs: { + description: { + story: + 'When `canManageByo` is true the picker renders the default ' + + 'edit/delete icons on every BYO row and a "Use custom model" ' + + 'CTA at the bottom of the popup. Wire `onUseCustomModel` to ' + + 'open your BYO connection wizard.', + }, + }, + }, +}; + +export const ViewerCannotManageByo: Story = { + name: 'Viewer: read-only BYO', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + homeRegion: 'EU', + // Default for `canManageByo` is false: viewers see the same BYO + // models as admins but can't mutate connections. The footer CTA + // is also suppressed. + canManageByo: false, + }, + parameters: { + docs: { + description: { + story: + 'Non-admin users see BYO models in the catalog but cannot ' + + 'edit/delete connections. No row actions, no "Use custom model" ' + + 'CTA. This is the default: opt in to admin affordances via ' + + '`canManageByo`.', + }, + }, + }, +}; + +export const ManyModelsStressTest: Story = { + name: 'Stress test. 500 models', + render: Controlled, + args: { + variant: 'virtualized', + models: Array.from({ length: 500 }, (_, i) => ({ + modelId: `synthetic-model-${i}`, + modelName: `synthetic-model-${i}`, + vendor: i % 3 === 0 ? 'OpenAi' : i % 3 === 1 ? 'AnthropicClaude' : 'VertexAi', + modelSubscriptionType: i % 5 === 0 ? 'BYOMAdded' : 'UiPathOwned', + isPreview: i % 11 === 0, + })), + groupBy: 'subscription', + }, +}; + +// --------------------------------------------------------------------------- +// "Section-missing" degradation stories: show what the picker looks like +// when one of the canonical groups (Recommended / Preview / Custom (BYO) / +// Deprecating soon) is empty for a given tenant. The `groupModels` util +// drops empty buckets, so the picker collapses cleanly with no visual +// artifacts. These stories validate that gracefully. +// --------------------------------------------------------------------------- + +const BYO_MODELS: DiscoveryModel[] = MOCK_MODELS.filter( + (m) => + m.modelSubscriptionType === 'BYOMAdded' || + m.modelSubscriptionType === 'BYOMReplacedAlternative' || + m.modelSubscriptionType === 'BYOMReplacedLikeForLike' +); +const PREVIEW_MODELS: DiscoveryModel[] = MOCK_MODELS.filter( + (m) => + m.isPreview && m.modelSubscriptionType === 'UiPathOwned' && !m.deprecationDetails?.usageEndDate +); +const RECOMMENDED_MODELS: DiscoveryModel[] = MOCK_MODELS.filter( + (m) => + m.modelSubscriptionType === 'UiPathOwned' && !m.isPreview && !m.deprecationDetails?.usageEndDate +); +const DEPRECATING_MODELS: DiscoveryModel[] = MOCK_MODELS.filter( + (m) => m.deprecationDetails?.usageEndDate +); + +export const NoRecommendedSection: Story = { + name: 'Section missing: no Recommended', + render: Controlled, + args: { + variant: 'searchable', + models: [...PREVIEW_MODELS, ...DEPRECATING_MODELS, ...BYO_MODELS], + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + value: PREVIEW_MODELS[0]?.modelId, + }, + parameters: { + docs: { + description: { + story: + 'Tenant has no stable UiPath-hosted models: only Preview, Deprecating, and BYO. ' + + 'The Recommended section is dropped entirely (no empty placeholder, no "0 models" heading). ' + + 'Picker still opens to the first Preview model.', + }, + }, + }, +}; + +export const NoCustomSection: Story = { + name: 'Section missing: no Custom (BYO)', + render: Controlled, + args: { + variant: 'searchable', + models: [...RECOMMENDED_MODELS, ...PREVIEW_MODELS, ...DEPRECATING_MODELS], + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'Tenant has not configured any BYO connections yet. The "Custom Models (BYO)" ' + + 'section disappears. This is the typical state for a fresh tenant.', + }, + }, + }, +}; + +export const NoPreviewSection: Story = { + name: 'Section missing: no Preview', + render: Controlled, + args: { + variant: 'searchable', + models: [...RECOMMENDED_MODELS, ...DEPRECATING_MODELS, ...BYO_MODELS], + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'No models in early access for this tenant. The Preview section is dropped: ' + + 'the catalog renders as Recommended + Deprecating + Custom.', + }, + }, + }, +}; + +export const OnlyRecommendedSection: Story = { + name: 'Section missing: only Recommended', + render: Controlled, + args: { + variant: 'searchable', + models: RECOMMENDED_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'Best-case minimal picker: only stable, UiPath-hosted models with no BYO, ' + + 'no preview, no deprecations. Demonstrates that with a single section the ' + + 'group header still renders so users know the scope.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Folder-scoped Custom Models: demonstrates the `popupHeader` slot used to +// host a folder switcher. The picker itself knows nothing about folders; +// the host (this story) owns the folder state and re-passes a different +// `models` array per folder. Mirrors the real backend behavior where +// `GET /api/discovery` accepts `X-UiPath-FolderKey` to scope BYO results +// (see useDiscoveryModels.ts). +// --------------------------------------------------------------------------- + +// Folder list used by the picker's built-in folder switcher. The +// switcher prepends an "All folders" sentinel automatically, so this +// list holds *real* folders only. `null` is the sentinel value. +const FOLDERS = [ + { id: 'shared', label: 'Shared' }, + { id: 'finance', label: 'Finance' }, + { id: 'engineering', label: 'Engineering' }, +]; + +// Per-folder BYO model lists. UiPath-hosted models stay constant across +// folders (they're tenant-wide); only BYO models change. The "all" +// case (`folder === null`) returns the union of every folder's BYO +// catalog deduped by `modelId`: what the backend returns when the +// caller omits `X-UiPath-FolderKey`. +function dedupeById(models: DiscoveryModel[]): DiscoveryModel[] { + const seen = new Set(); + const out: DiscoveryModel[] = []; + for (const m of models) { + if (seen.has(m.modelId)) continue; + seen.add(m.modelId); + out.push(m); + } + return out; +} + +const BYO_BY_FOLDER: Record = { + shared: BYO_MODELS, // everything visible at the tenant root + finance: BYO_MODELS.filter((m) => m.byoConnectionLabel?.includes('CignaSandbox')), + engineering: BYO_MODELS.filter((m) => /Bedrock|Vertex/.test(m.byoConnectionLabel ?? '')), +}; +const ALL_FOLDERS_BYO = dedupeById(Object.values(BYO_BY_FOLDER).flat()); + +const ControlledWithFolderScope = (args: React.ComponentProps) => { + const [folder, setFolder] = useState(null); + const [value, setValue] = useState('anthropic.claude-sonnet-4-6-20260301-v1:0'); + const modelsForFolder = useMemo( + () => [ + ...RECOMMENDED_MODELS, + ...PREVIEW_MODELS, + ...DEPRECATING_MODELS, + ...(folder == null ? ALL_FOLDERS_BYO : (BYO_BY_FOLDER[folder] ?? [])), + ], + [folder] + ); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + folders={FOLDERS} + folder={folder} + onFolderChange={setFolder} + /> +
+
+ ); +}; + +export const FolderScopedCustomModels: Story = { + name: 'Folder-scoped Custom Models', + render: ControlledWithFolderScope, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates the `popupHeader` slot used to host a folder switcher above ' + + 'the search input. Switching folders re-passes a different `models` array: ' + + 'the picker itself has no concept of folders, and UiPath-hosted models stay ' + + "constant across folders (they're tenant-wide). In a real consumer, the host " + + 'would re-fetch Discovery with the `X-UiPath-FolderKey` header on folder change.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// "Use custom model" footer. Studio Web / Autopilot agent authoring +// pattern. Tenant has only UiPath-hosted models; the picker exposes a +// CTA that takes the user to the BYO connection page. The picker +// renders the CTA itself when `canManageByo` is true; `onUseCustomModel` +// is the navigation hook. +// --------------------------------------------------------------------------- + +const ControlledWithCustomModelCta = (args: React.ComponentProps) => { + const [value, setValue] = useState('uipath-gpt-41-mini'); + // Tenant has only UiPath-hosted models in this story: no BYO yet. The + // "Use custom model" CTA is what gets them started. + const tenantModels = useMemo( + () => MOCK_MODELS.filter((m) => m.modelSubscriptionType === 'UiPathOwned'), + [] + ); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + canManageByo + onUseCustomModel={() => { + // In production: navigate to the BYO connections page. + // eslint-disable-next-line no-console + console.log('[story] navigate to /byo-models'); + }} + /> +
+
+ ); +}; + +export const WithUseCustomModelCta: Story = { + name: 'With “Use custom model” footer CTA', + render: ControlledWithCustomModelCta, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'When `canManageByo` is true the picker renders a default ' + + '"Use custom model" CTA at the bottom of the popup. Clicking ' + + 'it closes the popup and calls `onUseCustomModel`: the host ' + + 'navigates to the BYO connection page. The picker no longer ' + + 'opens a wizard dialog inline.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Routing substitution: what to show when the user's selected model has +// been retired and the gateway is silently routing traffic to a different +// upstream. The `Routes to …` chip renders on the trigger and on the row, +// the same way every other status chip does. +// --------------------------------------------------------------------------- + +const ControlledSelectingSubstitutedModel = (args: React.ComponentProps) => { + const [value, setValue] = useState('uipath-gpt-5-2025-08-07'); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + /> +
+
+ ); +}; + +export const RoutingSubstitution: Story = { + name: 'Routing substitution', + render: ControlledSelectingSubstitutedModel, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'The selected model `gpt-5-2025-08-07` has been retired. The gateway ' + + 'is silently routing its traffic to `gpt-6-2026-03-15`. The trigger ' + + 'shows the stored selection with a `Routes to gpt-6-2026-03-15` ' + + 'warning chip: the same chip the corresponding popup row carries: ' + + 'so the user sees what their config says vs. what is actually ' + + 'running, presented consistently with every other status chip.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Recommended from the Discovery DTO + cost badges as a custom-tag +// example (the agents product pattern). +// +// In production the Recommended signal is authored in +// `Model_hub/.yaml` (gitops-centralized-cluster) and merged +// into the Discovery response server-side: the picker reads it off +// `model.isRecommended`. Cost tiers are NOT a built-in signal: products +// that want them (agents does) stamp them via `customTagsFor`. +// --------------------------------------------------------------------------- + +// What the Discovery response looks like once the backend merges +// Model_hub: two Sonnets flagged isRecommended, everything else not. +const DISCOVERY_WITH_RECOMMENDED: DiscoveryModel[] = MOCK_MODELS.map((m) => ({ + ...m, + isRecommended: [ + 'anthropic.claude-sonnet-4-6-20260301-v1:0', + 'anthropic.claude-sonnet-4-5-20250929-v1:0', + ].includes(m.modelId), +})); + +// The agents product's cost badges, built on the exported example +// classifier. Cost is a per-product decision: this is the whole +// integration, no picker feature required. +const COST_TIER_LABELS: Record = { + basic: 'Basic', + standard: 'Standard', + premium: 'Premium', +}; +const agentsCostBadges = (m: DiscoveryModel) => { + const tier = defaultCostTier(m); + return tier ? [{ kind: `cost-${tier}`, label: COST_TIER_LABELS[tier] ?? tier }] : []; +}; + +export const RecommendedFromDiscovery: Story = { + name: 'Recommended from Discovery + cost badges (agents example)', + render: Controlled, + args: { + variant: 'searchable', + models: DISCOVERY_WITH_RECOMMENDED, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + customTagsFor: agentsCostBadges, + value: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates two production patterns. **(1)** Recommended is ' + + 'read from the Discovery DTO (`model.isRecommended`): the ' + + 'backend merges `Model_hub/.yaml` from ' + + 'gitops-centralized-cluster into the response, so neither the ' + + 'picker nor the product fetches Model_hub. Note Sonnet 4.6 is ' + + 'Recommended here even though its DTO says `isPreview: true`: ' + + 'the merged field wins over the local heuristic. **(2)** The ' + + '`Basic` / `Standard` / `Premium` chips are NOT a picker ' + + 'feature: they are product badges stamped via `customTagsFor` ' + + 'using the exported `defaultCostTier` example classifier, the ' + + 'way the agents product does it.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Kitchen Sink: every picker capability turned on at once. Useful as the +// "if a designer wants to see EVERYTHING" reference + as a smoke test for +// the built-in folder switcher, default `canManageByo` footer CTA, +// recommended/preview overrides, cost tier, view toggle, and substitution. +// --------------------------------------------------------------------------- + +const KITCHEN_RECOMMENDED_IDS = [ + 'anthropic.claude-sonnet-4-6-20260301-v1:0', + 'anthropic.claude-sonnet-4-5-20250929-v1:0', +]; +const KITCHEN_PREVIEW_IDS = ['gpt-5-2025-08-07', 'gemini-3-flash-preview-20260215']; + +const ControlledKitchenSink = (args: React.ComponentProps) => { + const [folder, setFolder] = useState(null); + const [value, setValue] = useState('anthropic.claude-sonnet-4-6-20260301-v1:0'); + + const modelsForFolder = useMemo( + () => [ + // Hosted models stay constant across folders. + ...MOCK_MODELS.filter((m) => m.modelSubscriptionType === 'UiPathOwned'), + // BYO models scope to the picked folder (or union when "all"). + ...(folder == null ? ALL_FOLDERS_BYO : (BYO_BY_FOLDER[folder] ?? [])), + ], + [folder] + ); + + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + recommendedModelIds={KITCHEN_RECOMMENDED_IDS} + previewModelIds={KITCHEN_PREVIEW_IDS} + customTagsFor={agentsCostBadges} + folders={FOLDERS} + folder={folder} + onFolderChange={setFolder} + canManageByo + onUseCustomModel={() => { + // In production: navigate to the BYO connections page. + // eslint-disable-next-line no-console + console.log('[story:kitchen-sink] navigate to /byo-models'); + }} + /> +
+
+ ); +}; + +export const KitchenSink: Story = { + name: 'Kitchen sink: everything on', + render: ControlledKitchenSink, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'Reference story exercising every capability at once: ' + + '**Model_hub overrides** (recommended/preview lists), ' + + '**cost tier chips** (Basic/Standard/Premium derived by ' + + '`defaultCostTier`), built-in **folder switcher** with the ' + + '"All folders" sentinel + per-folder BYO scoping, **view ' + + 'toggle** (Category ⇆ Provider), default **"Use custom ' + + 'model" footer CTA** that calls `onUseCustomModel` (host ' + + 'navigates to the BYO connections page), **substitution ' + + 'marker** on the substituted gpt-5 row, **deprecating chip** ' + + 'on gpt-4o, and **out-of-region warning** on gemini-flash for ' + + 'EU users.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Unknown model: what the picker shows when the host's stored `value` +// is not in the catalog returned by Discovery. Possible causes: model +// retired with no routing rule, customer migrated tenants, BYO +// connection deleted, stale config blob loaded. +// --------------------------------------------------------------------------- + +const ControlledUnknownModel = (args: React.ComponentProps) => { + const [value, setValue] = useState('org-default-gpt-3-5-deprecated'); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + /> +
+
+ ); +}; + +export const UnknownModelFallback: Story = { + name: 'Unknown model: graceful fallback', + render: ControlledUnknownModel, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + }, + parameters: { + docs: { + description: { + story: + 'The host passed `value="org-default-gpt-3-5-deprecated"`, but ' + + 'that id is not present in the Discovery catalog the picker ' + + 'received. Rather than silently dropping the value: which ' + + 'would look identical to "nothing selected" and risk ' + + 'accidental overwrites of a stored config: the trigger ' + + 'renders the raw id in error red with the border in error ' + + 'red. The trigger is also marked `aria-invalid`, so any ' + + 'host-provided form validation picks up the broken state. ' + + 'Opening the dropdown lets the user pick a replacement, ' + + 'which clears the unknown state.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Friendly names: the picker's "friendly" mode where each row shows a +// human label up top with the canonical model id in a monospace +// secondary line. Demonstrates `friendlyNameFor`: a product-controlled +// map that lets each FPS team localize or rebrand the catalog without +// touching the design system. +// --------------------------------------------------------------------------- + +const FRIENDLY_NAMES: Record = { + 'anthropic.claude-sonnet-4-6-20260301-v1:0': 'Claude Sonnet 4.6', + 'anthropic.claude-sonnet-4-5-20250929-v1:0': 'Claude Sonnet 4.5', + 'gemini-3-flash-preview-20260215': 'Gemini 3 Flash', + 'gpt-5-2025-08-07': 'GPT-5', + 'gpt-4o-2024-08-06': 'GPT-4o', + 'uipath-gpt-41-mini': 'GPT-4.1 Mini', + 'shared-haiku-4-5': 'Claude Haiku 4.5', + 'shared-sonnet-4-5': 'Claude Sonnet 4.5 (Bedrock)', +}; + +export const WithFriendlyNames: Story = { + name: 'With friendly names (per-product label map)', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + friendlyNameFor: (m) => FRIENDLY_NAMES[m.modelId] ?? null, + value: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + }, + parameters: { + docs: { + description: { + story: + 'Products hand the picker a `friendlyNameFor` function and ' + + 'the row primary line becomes the human label (e.g. "Claude ' + + 'Sonnet 4.6") with the canonical model id in a monospace ' + + 'secondary line. Models without an entry fall back to the ' + + 'raw `modelName`. The trigger uses the same function so the ' + + 'selected label stays consistent across the trigger + popup.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Custom badges. `customTagsFor` lets products stamp product-specific +// chips on rows (e.g. "Multimodal", "On-Prem") without forking the +// component. New tag kinds get colored via `customTagVariants`. +// --------------------------------------------------------------------------- + +export const WithCustomBadges: Story = { + name: 'With custom badges (per-product chips)', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + friendlyNameFor: (m) => FRIENDLY_NAMES[m.modelId] ?? null, + // Two product-specific chips. The picker doesn't know what + // "multimodal" or "onprem" mean: it just renders them. + customTagsFor: (m) => { + const tags = [] as Array<{ + kind: string; + label: string; + tooltip?: string; + }>; + // Mark models that have both text + vision modalities. (Hardcoded + // here for the story: in production this would come from a + // product config or a model_hub field.) + if ( + m.modelId.includes('claude') || + m.modelId.includes('gpt-4o') || + m.modelId.includes('gemini') + ) { + tags.push({ kind: 'multimodal', label: 'Multimodal' }); + } + // Mark BYO models hosted on an on-prem connection. + if (m.byoConnectionLabel?.toLowerCase().includes('irs')) { + tags.push({ + kind: 'onprem', + label: 'On-prem', + tooltip: 'Routes to an on-prem connection', + }); + } + return tags; + }, + customTagVariants: { + multimodal: 'info-mini', + onprem: 'warning-mini', + }, + value: 'anthropic.claude-sonnet-4-6-20260301-v1:0', + }, + parameters: { + docs: { + description: { + story: + 'Products can stamp their own chips via `customTagsFor`: the ' + + 'picker concatenates them after the built-in chips. ' + + 'Unknown tag kinds render with a neutral gray pill by ' + + 'default; pass `customTagVariants` to color them. Here we ' + + 'add `Multimodal` (info blue) and `On-prem` (warning amber) ' + + 'chips. Combine with `friendlyNameFor` for the full ' + + '"product-owned catalog" experience.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Filter: per-product scoping. FPS teams scope the picker to a subset +// of the catalog (e.g. only models that support a given operation +// code, or only the ones the current user has access to). +// --------------------------------------------------------------------------- + +export const WithFilter: Story = { + name: 'With per-product filter', + render: Controlled, + args: { + variant: 'searchable', + models: MOCK_MODELS, + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + // Scope the picker to UiPath-hosted + BYO models that have a + // routing geography of `EU` or `GLOBAL`. Filters out the + // `US`-routed Bedrock connections so an EU tenant sees only the + // models its workloads can legally reach. + filter: (m) => { + const geo = m.routingDetails?.geography; + if (!geo) return true; + return geo === 'EU' || geo === 'GLOBAL'; + }, + }, + parameters: { + docs: { + description: { + story: + 'Pass `filter={(model) => ...}` to scope the visible catalog. ' + + 'The filter runs **before** grouping and search, so empty ' + + 'sections drop out cleanly. Combine with ' + + '`recommendedModelIds`/`previewModelIds` to keep grouping ' + + 'consistent with the post-filter catalog.', + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Dark mode: the picker reads every color through Apollo's --color-* +// custom properties, so flipping the variable set on any ancestor +// re-skins it with no theme provider involved. This story pins the +// dark values inline (from docs/COLOR-TOKENS.md, dark column) to prove +// the web-component story: no MUI ThemeProvider, just CSS variables. +// --------------------------------------------------------------------------- + +const DARK_MODE_VARS: Record = { + '--color-background': '#182027', + '--color-background-secondary': '#273139', + '--color-background-raised': '#273139', + '--color-background-selected': '#374652', + '--color-background-hover': 'rgba(207, 216, 221, 0.078)', + '--color-foreground': '#f4f5f7', + '--color-foreground-de-emp': '#cfd8dd', + '--color-foreground-disable': '#a4b1b8', + '--color-foreground-light': '#a4b1b8', + '--color-border-de-emp': '#526069', + '--color-border-grid': '#273139', + '--color-primary': '#66adff', + '--color-primary-hover': '#87bfff', + '--color-primary-focused': 'rgba(102, 173, 255, 0.3)', + '--color-primary-lighter': 'rgba(102, 173, 255, 0.15)', + '--color-error-text': '#ff8484', + '--color-warning-text': '#ffe19e', +}; + +const ControlledDarkMode = (args: React.ComponentProps) => { + const [value, setValue] = useState('anthropic.claude-sonnet-4-6-20260301-v1:0'); + return ( +
+
+ { + setValue(m.modelId); + args.onChange?.(m); + }} + /> +
+
+ ); +}; + +export const DarkMode: Story = { + name: 'Dark mode (CSS variables only)', + render: ControlledDarkMode, + args: { + variant: 'searchable', + label: 'Model', + required: true, + groupBy: 'subscription', + homeRegion: 'EU', + customTagsFor: agentsCostBadges, + }, + parameters: { + docs: { + description: { + story: + 'The picker re-skins entirely through the `--color-*` custom ' + + 'properties: no MUI ThemeProvider involved. This story pins ' + + "Apollo's dark-theme variable values on a wrapper div, exactly " + + 'how a web-component host flips themes. Every surface (trigger, ' + + 'toolbar, rows, section bands, chips, footer CTA) adapts.', + }, + }, + }, +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx new file mode 100644 index 000000000..5b21d3a9a --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelPicker } from './ModelPicker'; +import type { DiscoveryModel } from './types'; + +/** + * Render helper. No I18nProvider on purpose: the picker resolves its + * strings through `useSafeLingui`, whose fallback formats the English + * defaults, and no MUI ThemeProvider either — the picker must work in + * bare web-component hosts. + */ +function renderPicker(ui: React.ReactElement) { + return render(ui); +} + +const MODELS: DiscoveryModel[] = [ + { + modelId: 'anthropic.claude-sonnet-4-6', + modelName: 'anthropic.claude-sonnet-4-6', + vendor: 'AnthropicClaude', + modelSubscriptionType: 'UiPathOwned', + }, + { + modelId: 'gpt-4o', + modelName: 'gpt-4o', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + isPreview: true, + }, + { + modelId: 'byo-cigna-gpt-4o', + modelName: 'gpt-4o', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'CignaSandbox', + }, +]; + +describe('', () => { + it('renders the trigger with the selected model name', () => { + renderPicker( + {}} /> + ); + expect(screen.getByText('anthropic.claude-sonnet-4-6')).toBeInTheDocument(); + }); + + it('renders the friendly name when friendlyNameFor is set', () => { + renderPicker( + {}} + friendlyNameFor={(m) => + m.modelId === 'anthropic.claude-sonnet-4-6' ? 'Claude Sonnet 4.6' : null + } + /> + ); + expect(screen.getByText('Claude Sonnet 4.6')).toBeInTheDocument(); + }); + + it('opens on click and fires onChange when a row is picked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderPicker(); + await user.click(screen.getByRole('button', { expanded: false })); + // Listbox should now exist with an accessible name. + const listbox = await screen.findByRole('listbox', { name: /models/i }); + expect(listbox).toBeInTheDocument(); + // Click an option. There are two `gpt-4o` rows (the hosted one and a + // BYO clone) — pick the hosted one by its option role + selected/active + // state walk via the modelName id. + const option = within(listbox).getByRole('option', { name: /^gpt-4o$/ }); + await user.click(option); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].modelId).toBe('gpt-4o'); + }); + + it('falls back to "unknown model" treatment when value does not match catalog', () => { + renderPicker( {}} />); + expect(screen.getByText('some-retired-model')).toBeInTheDocument(); + // Trigger should be aria-invalid. + expect(screen.getByRole('button', { expanded: false })).toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders BYO edit/delete actions only when canManageByo is true', async () => { + const user = userEvent.setup(); + const { rerender } = renderPicker( + {}} canManageByo={false} /> + ); + await user.click(screen.getByRole('button', { expanded: false })); + // BYO is the first group (top of Category view) and expanded by + // default, so rows are visible without an extra click. + await screen.findByRole('listbox'); + // Viewer: no edit/delete buttons on the BYO row. + expect(screen.queryByRole('button', { name: /edit connection/i })).toBeNull(); + + rerender( + {}} + canManageByo + onUseCustomModel={() => {}} + /> + ); + // After flipping canManageByo on, edit + delete render. + expect(await screen.findByRole('button', { name: /edit connection/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove connection/i })).toBeInTheDocument(); + // Footer CTA also appears. + expect(screen.getByText(/use custom model/i)).toBeInTheDocument(); + }); + + it('Use custom model CTA closes the popup and calls onUseCustomModel', async () => { + const user = userEvent.setup(); + const onUseCustomModel = vi.fn(); + renderPicker( + {}} + canManageByo + onUseCustomModel={onUseCustomModel} + /> + ); + await user.click(screen.getByRole('button', { expanded: false })); + const cta = await screen.findByText(/use custom model/i); + await user.click(cta); + expect(onUseCustomModel).toHaveBeenCalledTimes(1); + // Popup is closed. + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('keyboard navigation: ArrowDown moves activedescendant, Enter selects, Escape closes', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderPicker(); + await user.click(screen.getByRole('button', { expanded: false })); + const search = await screen.findByRole('combobox'); + // Initial active descendant is set on open. + expect(search).toHaveAttribute('aria-activedescendant'); + const firstActive = search.getAttribute('aria-activedescendant'); + // Move down. + await user.keyboard('{ArrowDown}'); + expect(search.getAttribute('aria-activedescendant')).not.toBe(firstActive); + // Pick. + await user.keyboard('{Enter}'); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx new file mode 100644 index 000000000..5790cb311 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx @@ -0,0 +1,901 @@ +import AddIcon from '@mui/icons-material/Add'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import { Colors, FontFamily } from '@uipath/apollo-core'; +import React from 'react'; +import { useSafeLingui } from '../../../i18n'; + +import { FolderSwitcher } from './primitives/FolderSwitcher'; +import { GroupedOptionList, optionDomId, VirtualOptionList } from './primitives/OptionList'; +import { PickerPopup } from './primitives/PickerPopup'; +import { PickerSearchInput } from './primitives/PickerSearchInput'; +import { PickerTrigger } from './primitives/PickerTrigger'; +import type { DiscoveryModel, ModelTag } from './types'; +import { useModelPickerState } from './useModelPickerState'; +import { type PlatformRequestContext, useCanManageByo, useUserFolders } from './usePlatformAccess'; +import type { DeriveModelTagsContext, GroupStrategy } from './utils'; + +export type ModelPickerVariant = 'searchable' | 'virtualized'; + +/** + * Selection callback. The picker calls `onChange(model)` with the full + * Discovery DTO — read `model.modelId` for the id. Hosts migrating from + * the legacy `(modelId, model)` signature can adapt at the call site: + * + * legacy(m.modelId, m)} ... /> + */ +export type ModelPickerChangeHandler = (model: DiscoveryModel) => void; + +/** + * Context handed to footer-type slots (`listFooter`, `popupFooter`). + * `close()` dismisses the popup — call it before navigating away so + * the picker doesn't linger over the next screen. + */ +export interface ModelPickerSlotContext { + selected: DiscoveryModel | null; + close: () => void; +} + +/** + * Optional slots. Each slot receives the picker's current selected model + * (or null) and the surrounding context. Use slots to add columns, footers, + * custom headers without forking the component. + */ +export interface ModelPickerSlots { + /** + * Extra content rendered to the right of the model name in the trigger, + * before the caret. Example: a small "effort" badge. + */ + triggerExtra?: (model: DiscoveryModel | null) => React.ReactNode; + /** + * Rendered above the search input in the popup. Use for a sticky CTA + * or a banner that spans the full width. Default: nothing. + * + * NOTE: for compact inline controls like a folder picker, prefer + * `searchLeading` — it puts the control on the same row as the search + * field, sharing chrome instead of stacking a separate banner. + */ + popupHeader?: () => React.ReactNode; + /** + * Rendered to the left of the search field, inline with it. Use for a + * folder scope picker, a "filter by tag" pill, or any control that + * scopes the visible options. + */ + searchLeading?: () => React.ReactNode; + /** + * Rendered directly under the option list, flush against the last row + * but inside the popup's main scroll/content region — *above* any + * `popupFooter`. Use for inline calls-to-action that should read as + * part of the list rather than a separate footer band (e.g., + * `+ Add custom model` styled like a list row). + */ + listFooter?: (ctx: ModelPickerSlotContext) => React.ReactNode; + /** + * Rendered below the option list in the popup, in its own banded + * footer with a top border + secondary background. Use for an effort + * picker, "Show all models" toggle, etc. + * + * When `canManageByo` is true and this slot is unset, the picker + * renders the default "Use custom model" CTA wired to + * `onUseCustomModel`. Pass a function here to replace the default + * footer, or `null` to suppress it entirely. + */ + popupFooter?: null | ((ctx: ModelPickerSlotContext) => React.ReactNode); + /** + * Per-row meta column (renders after the model name + chips, before + * row actions). Use for cost bars, context window indicators, etc. + */ + optionMeta?: (model: DiscoveryModel) => React.ReactNode; + /** + * Per-row right-aligned actions. Default renders edit/delete for BYO + * models (only when `canManageByo` is true). Pass null to suppress. + */ + optionActions?: (model: DiscoveryModel) => React.ReactNode; +} + +export interface ModelPickerProps { + /** The catalog to render, typically from the LLM Gateway Discovery API. */ + models: DiscoveryModel[]; + /** Selected `modelId`, or `null`/`undefined` for no selection. */ + value?: string | null; + /** + * Selection callback — receives the picked `DiscoveryModel`. See + * `ModelPickerChangeHandler` above for the migration note from the + * legacy `(modelId, model)` shape. + */ + onChange?: ModelPickerChangeHandler; + /** Field label above the trigger. Defaults to a localized "Model". */ + label?: string; + /** Marks the field required: `aria-required` + a visual asterisk. */ + required?: boolean; + /** + * Trigger text when nothing is selected. Defaults to a localized + * "Select a model". + */ + placeholder?: string; + /** Disables the trigger. */ + disabled?: boolean; + /** Paints the trigger border error-red and sets `aria-invalid`. */ + invalid?: boolean; + /** + * Error message under the trigger (`role="alert"`, associated to the + * trigger via `aria-describedby`). + */ + errorText?: string; + /** + * Which option-list renderer to use. Default: `searchable`, which + * renders every row but automatically switches to the virtualized + * renderer when more than 120 options are visible. Pass + * `virtualized` to force virtualization regardless of count. + */ + variant?: ModelPickerVariant; + /** + * Initial grouping strategy. The picker holds this as internal state + * once mounted so the in-popup view toggle (see + * `allowGroupingChange`) can update it without lifting state to the + * host. Default: `subscription`. + */ + groupBy?: GroupStrategy; + /** + * Show the in-popup grouping pill (Category ⇆ Provider) on the + * toolbar. Default: `true`. + */ + allowGroupingChange?: boolean; + /** User's home region (e.g. `'EU'`). Used to flag out-of-region models. */ + homeRegion?: string; + /** + * Test/storybook override for the Recommended signal. In production + * the signal arrives ON the Discovery DTO (`model.isRecommended`) — + * the backend merges `Model_hub/.yaml` from + * `gitops-centralized-cluster` into the response, so products do NOT + * fetch Model_hub or pass this prop. When set (even as an empty + * array), only listed ids count as Recommended. + */ + recommendedModelIds?: readonly string[]; + /** + * Test/storybook override for the Preview signal. Production sources + * it from the DTO's `isPreview`. + */ + previewModelIds?: readonly string[]; + /** + * Per-product filter applied to the catalog *before* grouping and + * search. The most common per-product control: an FPS team scopes + * the picker to (e.g.) only models that match a given operation + * code, or only the ones the current user has access to. Pass a + * stable reference if `models` is large. + */ + filter?: (model: DiscoveryModel) => boolean; + /** + * Map a Discovery DTO to a human label. When set, the row's primary + * line shows this (e.g. "Claude Sonnet 4.6") and the technical id + * (`anthropic.claude-sonnet-4-6-...`) renders as a secondary monospace + * line — matching the design's "friendly" mode. The trigger uses the + * same function so the selected label stays consistent everywhere. + * Return `null`/`undefined` to keep the raw `modelName` for a + * specific row. + */ + friendlyNameFor?: (model: DiscoveryModel) => string | null | undefined; + /** + * Augment the chips on each row + trigger. Returned tags are + * concatenated after the built-in derived tags (Recommended, Preview, + * Custom, Deprecating, Out-of-region, Substituted). Products stamp + * arbitrary badges here — cost tiers (see `defaultCostTier` for the + * agents example), "Multimodal", "Routes via On-Prem", etc. + */ + customTagsFor?: (model: DiscoveryModel) => readonly ModelTag[]; + /** + * Apollo MUI chip variant lookup for *new* tag kinds the host + * introduces via `customTagsFor`. Built-in kinds keep their existing + * variant. Pass to color custom tags without forking ModelTagChip, + * e.g. `customTagVariants={{ multimodal: 'info-mini' }}`. + */ + customTagVariants?: Record; + /** + * Explicit override for BYO management affordances (edit/delete row + * actions + "Use custom model" footer CTA). + * + * **Leave it unset** and pass `requestContext` instead: the picker + * then checks the `AiTrustLayerByoLlm` license entitlement itself — + * the same signal the Experiences portal uses to show BYO LLM + * management. Set `true`/`false` only when your product has its own + * authorization model. With neither an override nor a + * `requestContext`, affordances stay hidden. + * + * A product that wants different actions can still override via + * `slots.optionActions`; a different footer can replace the default + * via `slots.popupFooter` (or `null` to suppress it). + */ + canManageByo?: boolean; + /** + * Called when the user activates the default "Use custom model" + * footer CTA (visible when BYO management is allowed and no custom + * `popupFooter` slot is provided). The picker closes itself before + * calling this — typically the host navigates to the BYO + * connections page. + */ + onUseCustomModel?: () => void; + /** + * Auth + routing context for the picker's built-in platform calls: + * the folder list (`enableFolders`) and the BYO-management + * entitlement check (`canManageByo` unset). Pass a **stable + * (memoized) object** — the internal hooks refetch when its identity + * changes. + */ + requestContext?: PlatformRequestContext; + /** + * Turn on folder scoping. The picker fetches the current user's + * Orchestrator folders itself (via `requestContext`) and renders the + * toolbar folder switcher — the product only decides *whether* + * folders apply to its surface. Wire `onFolderChange` and re-fetch + * Discovery with the new `folderKey` when the selection changes. + * Default: `false`. + */ + enableFolders?: boolean; + /** + * Test/storybook override for the folder list. When set, the picker + * skips its internal folder fetch and renders these instead. + * Production hosts should prefer `enableFolders` + `requestContext`. + */ + folders?: ReadonlyArray<{ id: string; label: string }>; + /** + * Selected folder id. `null` means the "All folders" sentinel (omit + * `X-UiPath-FolderKey` on the Discovery request). + */ + folder?: string | null; + /** Folder change callback. */ + onFolderChange?: (next: string | null) => void; + /** Label for the "All folders" sentinel. Default: `'All folders'`. */ + allFoldersLabel?: string; + /** Shows a spinner in the popup while the catalog loads. */ + loading?: boolean; + /** + * Catalog fetch error. Renders the message in the popup + * (`role="alert"`) and paints the trigger invalid. + */ + error?: Error | null; + /** + * Show section header rows (`CUSTOM MODELS (BYO)` / `RECOMMENDED` / + * `PREVIEW` / `DEPRECATING SOON`) between groups. Default: `true`. + * + * Regardless of this setting, models stay ordered by group — BYO + * first, then Recommended, Preview, More, Deprecating. + */ + showGroupHeaders?: boolean; + /** Extensibility slots. See `ModelPickerSlots`. */ + slots?: ModelPickerSlots; +} + +// Visual-only marker. The input is announced as required via +// `aria-required` (and `aria-invalid` when blank + invalid), so the +// asterisk is decorative — wrapped in `aria-hidden` below. +const REQUIRED_INDICATOR = '*'; + +// Above this many visible options the `searchable` variant hands the +// list to the virtualized renderer automatically. Protects hosts with +// big catalogs that never read the `variant` docs. Chosen so typical +// tenant catalogs (< 100 models) keep the simpler non-virtual DOM. +const AUTO_VIRTUALIZE_THRESHOLD = 120; + +/** + * The forwarded ref points at the trigger button, so hosts can focus + * the picker programmatically (e.g. after a validation failure). + */ +export const ModelPicker = React.forwardRef( + ( + { + models, + value, + onChange, + label, + required, + placeholder, + disabled, + invalid, + errorText, + variant = 'searchable', + groupBy = 'subscription', + allowGroupingChange = true, + homeRegion, + recommendedModelIds, + previewModelIds, + filter, + friendlyNameFor, + customTagsFor, + customTagVariants, + canManageByo, + onUseCustomModel, + requestContext, + enableFolders = false, + folders, + folder = null, + onFolderChange, + allFoldersLabel, + loading, + error, + showGroupHeaders = true, + slots, + }, + forwardedRef + ) => { + // useSafeLingui resolves against the host's I18nProvider when one is + // mounted and falls back to the English defaults baked into each + // descriptor otherwise — design-system components must not throw in + // providerless hosts (see src/i18n/useSafeLingui.ts). + const { _ } = useSafeLingui(); + const i18n = React.useMemo(() => ({ _ }), [_]); + + const resolvedLabel = label ?? _({ id: 'modelPicker.label.default', message: 'Model' }); + const resolvedPlaceholder = + placeholder ?? + _({ + id: 'modelPicker.placeholder.selectAModel', + message: 'Select a model', + }); + + // BYO sits at the top of both views and starts expanded — collapsing + // it by default would hide the most-requested section. The header + // still renders a chevron so users can collapse it manually if they + // want to focus on the hosted catalog. + const initiallyCollapsedGroups = React.useMemo(() => [], []); + const state = useModelPickerState({ + models, + value, + onChange, + groupBy, + recommendedModelIds, + previewModelIds, + filter, + initiallyCollapsedGroups, + i18n, + }); + const { + open, + setOpen, + query, + setQuery, + groupBy: activeGroupBy, + setGroupBy, + filtered, + groupCounts, + collapsedGroups, + toggleGroup, + selected, + unknownValue, + activeIndex, + setActiveIndex, + onSearchKeyDown, + choose, + id, + triggerRef, + searchRef, + } = state; + + const listboxId = `${id}-listbox`; + // Auto-virtualize large filtered sets so hosts don't need to know + // about the `variant` prop to stay smooth on big catalogs. + const List = + variant === 'virtualized' || filtered.length > AUTO_VIRTUALIZE_THRESHOLD + ? VirtualOptionList + : GroupedOptionList; + + // Expose the trigger element on the forwarded ref while keeping the + // hook's internal ref (focus-return + popup anchoring) wired. + const handleTriggerRef = React.useCallback( + (node: HTMLButtonElement | null) => { + (triggerRef as React.MutableRefObject).current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, + [forwardedRef, triggerRef] + ); + + const closePopup = React.useCallback(() => setOpen(false), [setOpen]); + const slotCtx = React.useMemo( + () => ({ selected, close: closePopup }), + [selected, closePopup] + ); + + // BYO management: an explicit `canManageByo` prop wins; otherwise + // the picker checks the AiTrustLayerByoLlm entitlement itself via + // `requestContext` (failing closed while loading / on error). + const { canManage: entitledToManageByo } = useCanManageByo( + canManageByo === undefined && requestContext ? requestContext : null + ); + const effectiveCanManageByo = canManageByo ?? entitledToManageByo ?? false; + + // Folder list: fetched internally when the product opts in via + // `enableFolders`, unless a test/storybook override supplies the + // list directly through `folders`. + const { folders: fetchedFolders } = useUserFolders( + enableFolders && !folders && requestContext ? requestContext : null + ); + const effectiveFolders = folders ?? (enableFolders ? fetchedFolders : undefined); + + // Dev-time guard: duplicate folder ids silently break the switcher's + // selection highlight. Warn once per list change. + React.useEffect(() => { + if (process.env['NODE_ENV'] === 'production' || !effectiveFolders) { + return; + } + const seen = new Set(); + for (const f of effectiveFolders) { + if (seen.has(f.id)) { + // eslint-disable-next-line no-console + console.warn( + `[ModelPicker] Duplicate folder id "${f.id}" — folder selection will misbehave.` + ); + return; + } + seen.add(f.id); + } + }, [effectiveFolders]); + + // Single tagContext value passed to both trigger + option rows so + // chip derivation stays consistent. Carries the active i18n instance + // so tag labels + tooltips render in the host's locale. + const tagContext = React.useMemo( + () => ({ + i18n, + homeRegion, + recommendedModelIds, + previewModelIds, + customTagsFor, + }), + [i18n, homeRegion, recommendedModelIds, previewModelIds, customTagsFor] + ); + + const selectedPrimaryLabel = selected ? (friendlyNameFor?.(selected) ?? null) : null; + + // In Category view the section header *is* the Recommended/Preview + // label — repeating it on every row inside the section is noise. + // Hide both chips on rows when grouped by subscription; keep them in + // Provider/flat views where the section header doesn't carry that + // signal. Trigger chips are unaffected (they carry the selection's + // status even when the popup is closed). + const rowHideTagKinds = React.useMemo( + () => (activeGroupBy === 'subscription' ? ['recommended', 'preview'] : undefined), + [activeGroupBy] + ); + + // Row actions: respect the slot override, otherwise gate the default + // edit/delete by the resolved BYO-management permission. Returning + // `undefined` lets OptionList fall back to its own default; passing + // `null` from a slot suppresses actions entirely. + const renderRowActions = React.useMemo(() => { + if (slots?.optionActions) return slots.optionActions; + if (!effectiveCanManageByo) return () => null; + return undefined; + }, [slots?.optionActions, effectiveCanManageByo]); + + // Footer: explicit slot override (including `null`) wins; otherwise + // the default "Use custom model" CTA appears when the user may + // manage BYO. Computed as a node (not a render function) so the + // popup receives stable children. + const footerNode = React.useMemo(() => { + if (slots && Object.hasOwn(slots, 'popupFooter')) { + return slots.popupFooter ? slots.popupFooter(slotCtx) : null; + } + if (!effectiveCanManageByo) return null; + return ( + { + closePopup(); + onUseCustomModel?.(); + }} + disabled={!onUseCustomModel} + /> + ); + }, [slots, effectiveCanManageByo, onUseCustomModel, slotCtx, closePopup]); + + return ( + // Typography comes from Apollo's Noto Sans stack directly (not the + // host's body font) so the picker renders identically in MUI apps, + // Angular shells, and bare web-component hosts. + + + {resolvedLabel} + {required && ( + + {REQUIRED_INDICATOR} + + )} + + + setOpen(!open)} + /> + + {(errorText ?? error) && ( + + {errorText ?? error?.message} + + )} + + + {slots?.popupHeader?.()} + { + setQuery(next); + setActiveIndex(0); + }} + onKeyDown={onSearchKeyDown} + inputRef={searchRef} + listboxId={listboxId} + activeDescendantId={ + filtered[activeIndex] + ? optionDomId(listboxId, filtered[activeIndex].modelId) + : undefined + } + leading={ + slots?.searchLeading?.() ?? + (effectiveFolders && effectiveFolders.length > 0 && onFolderChange ? ( + + ) : undefined) + } + trailing={ + allowGroupingChange ? ( + + ) : undefined + } + /> + + } + footer={footerNode} + > + {loading && ( + + + + )} + {error && !loading && ( + + {error.message} + + )} + {!loading && !error && filtered.length === 0 && ( + + {query.trim() + ? _({ + id: 'modelPicker.empty.noMatch', + message: 'No models match "{query}".', + values: { query: query.trim() }, + }) + : _({ + id: 'modelPicker.empty.noModels', + message: 'No models available.', + })} + + )} + {/* + Offscreen result-count announcement. Updates whenever the + filtered set changes so screen-reader users hear "5 models" / + "1 model" / "no models" as they type, without us having to + interrupt the rest of the popup. Visually hidden via the + standard SR-only pattern (1×1 px, clipped, no margin). + */} + + {!loading && !error && filtered.length > 0 + ? filtered.length === 1 + ? _({ + id: 'modelPicker.count.one', + message: '{n} model', + values: { n: filtered.length }, + }) + : _({ + id: 'modelPicker.count.many', + message: '{n} models', + values: { n: filtered.length }, + }) + : ''} + + {!loading && !error && filtered.length > 0 && ( + <> + + {slots?.listFooter?.(slotCtx)} + + )} + + + ); + } +); + +ModelPicker.displayName = 'ModelPicker'; + +// --------------------------------------------------------------------------- +// Group-by segmented control (Category ⇆ Provider). +// +// Implemented as a pill segmented control on the toolbar — matches the +// design handoff's "Group by" pill, not the earlier icon-button approach. +// `subscription` maps to "Category" so end users see the friendlier word. +// --------------------------------------------------------------------------- + +interface GroupBySegmentedProps { + value: GroupStrategy; + onChange: (next: GroupStrategy) => void; +} + +const GroupBySegmented: React.FC = ({ value, onChange }) => { + const { _ } = useSafeLingui(); + const groupByOptions: Array<{ key: GroupStrategy; label: string }> = [ + { + key: 'subscription', + label: _({ id: 'modelPicker.groupBy.category', message: 'Category' }), + }, + { + key: 'vendor', + label: _({ id: 'modelPicker.groupBy.provider', message: 'Provider' }), + }, + ]; + return ( + + {groupByOptions.map((opt) => { + const active = opt.key === value; + return ( + onChange(opt.key)} + aria-pressed={active} + sx={{ + fontSize: 12.5, + fontWeight: 600, + lineHeight: 1.2, + px: 1.25, + py: 0.625, + borderRadius: '6px', + color: active + ? `var(--color-primary, ${Colors.ColorBlue500})` + : `var(--color-foreground-de-emp, ${Colors.ColorGray550})`, + backgroundColor: active + ? `var(--color-background-raised, ${Colors.ColorWhite})` + : 'transparent', + boxShadow: active ? '0 1px 2px rgba(16, 24, 40, 0.14)' : 'none', + transition: 'background-color 120ms, color 120ms, box-shadow 120ms', + '&:hover': { + backgroundColor: active + ? `var(--color-background-raised, ${Colors.ColorWhite})` + : `var(--color-background-hover, rgba(82, 96, 105, 0.078))`, + }, + }} + > + {opt.label} + + ); + })} + + ); +}; + +// --------------------------------------------------------------------------- +// Default "Use custom model" footer CTA. +// +// Visible when `canManageByo` is true and no `popupFooter` slot is +// supplied. Full-width tappable band with a small primary tile + title +// + subtitle — matches the design handoff exactly. +// --------------------------------------------------------------------------- + +interface UseCustomModelFooterProps { + onActivate: () => void; + /** + * Render the CTA as a static (non-tappable) hint when the host + * forgot to wire `onUseCustomModel`. The CTA still shows so the + * BYO affordance is visible — it just doesn't crash on click and + * surfaces a tooltip explaining why. + */ + disabled?: boolean; +} + +const UseCustomModelFooter: React.FC = ({ onActivate, disabled }) => { + const { _ } = useSafeLingui(); + return ( + + + + + + + {_({ + id: 'modelPicker.useCustomModel.title', + message: 'Use custom model', + })} + + + {_({ + id: 'modelPicker.useCustomModel.subtitle', + message: 'Bring a model from your own connection', + })} + + + + ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx new file mode 100644 index 000000000..b9874981d --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx @@ -0,0 +1,138 @@ +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import PublicOffIcon from '@mui/icons-material/PublicOff'; +import RouteIcon from '@mui/icons-material/Route'; +import ScienceOutlinedIcon from '@mui/icons-material/ScienceOutlined'; +import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; +import Chip from '@mui/material/Chip'; +import Tooltip from '@mui/material/Tooltip'; +import type React from 'react'; + +import type { ModelTag } from './types'; + +/** + * Each tag kind maps to one of Apollo's semantic Chip variants + * (`.success | .warning | .info | .error`) — defined in + * `@uipath/apollo-mui5/src/overrides/MuiChip.ts`. We use the `-mini` suffix + * to get the compact 16px-tall semibold pill that matches Apollo's tag + * pattern. Kinds without a clean semantic mapping fall back to the + * default mini chip (gray, neutral). + * + * Mapping rationale: + * recommended → success (positive, evaluation-backed) + * preview → info (informational, new) + * deprecating → warning (caution before removal) + * out-of-region → error (compliance risk) + * custom / thinking → neutral mini + * + * Cost tiers render as neutral gray mini chips intentionally — using the + * semantic warning/error palette there would imply that high-cost + * models are *risky*, which they aren't. + */ +const VARIANT_MAP: Record = { + recommended: 'success-mini', + preview: 'info-mini', + deprecating: 'warning-mini', + // Substitution is the post-retirement state of `deprecating` — the user's + // stored selection now points at a model whose traffic is being routed + // elsewhere. Same warning semantic so retirement-related signals share + // a visual language as they progress (scheduled → in effect). + substituted: 'warning-mini', + 'out-of-region': 'error-mini', + custom: 'mini', + thinking: 'info-mini', + 'cost-basic': 'mini', + 'cost-standard': 'mini', + 'cost-premium': 'mini', +}; + +// Icon glyphs for built-in tag kinds. Picked from `@mui/icons-material` +// because that's already used elsewhere in apollo-react (no new dep); +// the design handoff's stroke-style glyphs (✓, ⚠, 🌐, ⤴) are best +// approximated by these outlined variants. +// +// We deliberately use *outlined* variants for the lifecycle / warning +// kinds — filled icons feel heavier and compete with the model name +// next to them. The check inside `Recommended` is the one exception: +// `CheckCircleOutline` matches the design's solid check glyph. +const ICON_MAP: Record> = { + recommended: CheckCircleOutlineIcon, + preview: ScienceOutlinedIcon, + deprecating: WarningAmberOutlinedIcon, + substituted: RouteIcon, + 'out-of-region': PublicOffIcon, + // `custom` and the cost-tier kinds stay icon-less — they're already + // tagged by color/shape and the label does the work. Adding glyphs + // there crowded the right column. +}; + +export interface ModelTagChipProps { + tag: ModelTag; + /** + * Optional product-supplied lookup for tag kinds the design system + * doesn't know about. Wins over the built-in map for matching keys. + * Use when `customTagsFor` introduces a new kind (e.g. + * `{ multimodal: 'info-mini', onprem: 'warning-mini' }`). + */ + variants?: Record; + /** + * Optional product-supplied icon lookup for custom tag kinds. Keys + * are tag kinds; values are React components rendered as the leading + * icon (sized 14px). Pass `null` for a kind to suppress the icon on + * built-in kinds. Tags without an entry render without an icon. + */ + icons?: Record | null>; +} + +export const ModelTagChip: React.FC = ({ tag, variants, icons }) => { + // Resolution order: inline override on the tag → host-supplied + // variants map → built-in map → neutral fallback. Inline first so a + // single tag can opt out of the default without disturbing other + // tags of the same kind. + const variantClass = tag.variant ?? variants?.[tag.kind] ?? VARIANT_MAP[tag.kind] ?? 'mini'; + + // Same precedence for icons: host map > built-in map. Host can pass + // `null` to suppress an icon on a built-in kind (e.g. for a compact + // surface). + const Icon = icons && tag.kind in icons ? icons[tag.kind] : ICON_MAP[tag.kind]; + + const chip = ( + + ) : undefined + } + sx={{ + // Apollo's `.-mini` overrides set height/font/padding + // to 0; we just need a small horizontal pad on the label. + '& .MuiChip-label': { px: 0.75 }, + // The leading icon needs a touch of breathing room from the + // label without inheriting the variant's text color directly + // (the icon should be the *same* color so the chip reads as + // one painted pill). + '& .MuiChip-icon': { + fontSize: 13, + marginLeft: 0.5, + marginRight: -0.25, + color: 'inherit', + }, + }} + /> + ); + + if (tag.tooltip) { + return ( + + {chip} + + ); + } + return chip; +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/README.md b/packages/apollo-react/src/material/components/ap-model-picker/README.md new file mode 100644 index 000000000..058ecf9ca --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/README.md @@ -0,0 +1,408 @@ +# ModelPicker + +Apollo's shared LLM model picker, built on the UiPath LLM Gateway Discovery API. Ships in `@uipath/apollo-react` under the Material component set (`@uipath/apollo-react/material/components`). + +It renders a labeled trigger that opens a popup with a built-in folder switcher, a search field, a Category ⇆ Provider grouping pill, grouped sections — Custom Models (BYO) always first — and a "Use custom model" footer for users who can manage BYO. + +The picker is **fully controlled** — your code owns the `value` and the catalog (`models[]`). The picker only renders the UI. + +--- + +## Quick start + +```tsx +import { ModelPicker, useDiscoveryModels } from '@uipath/apollo-react/material/components'; + +const { models, loading, error } = useDiscoveryModels({ + token, + accountId, + tenantId, + requestingProduct: 'agents', + requestingFeature: 'design-eval-deploy', +}); + +const [value, setValue] = React.useState(null); + + setValue(model.modelId)} +/> +``` + +That's the minimum. The rest of this document covers the per-product customization surface. + +--- + +## Per-product customization + +The picker is one shared visual + behavioral contract. Everything below is a prop on `` — no forks, no wrappers, no design-system PRs. + +### 1. Filter the visible catalog + +When your product should only show a subset of what Discovery returns (specific operation codes, current-user permissions, region constraints), pass a `filter`: + +```tsx + + m.byomDetails?.availableOperationCodes?.includes('agents-design-eval-deploy') ?? + m.modelSubscriptionType === 'UiPathOwned' + } + value={value} + onChange={(m) => setValue(m.modelId)} +/> +``` + +**Notes:** + +- `filter` runs **before** grouping and search. Empty groups disappear cleanly. +- A `value` whose id is filtered out still resolves — the trigger renders normally, no spurious "unknown model" error. +- Pass a **stable function reference** if `models` is large; the hook re-derives groups whenever `filter` changes identity. + +### 2. Friendly names + +Replace the technical model id with a human label on every row + the trigger. The technical id renders as a monospace secondary line so it's still copyable. + +```tsx +const FRIENDLY_NAMES: Record = { + 'anthropic.claude-sonnet-4-6-20260301-v1:0': 'Claude Sonnet 4.6', + 'gpt-5-2025-08-07': 'GPT-5', + 'gpt-4o-2024-08-06': 'GPT-4o', + 'gemini-3-flash-preview-20260215': 'Gemini 3 Flash', +}; + + FRIENDLY_NAMES[m.modelId] ?? null} + value={value} + onChange={(m) => setValue(m.modelId)} +/> +``` + +Returning `null`/`undefined` for a model falls back to its raw `modelName`. You can also derive the label dynamically — fetching from a config service, mapping by `modelFamily`, etc. + +**Best practice:** keep the friendly-name map in your product config rather than hardcoding it next to the component. + +### 3. Custom badges + +The picker derives lifecycle chips automatically (Recommended, Preview, Deprecating, Substituted, Custom, Out-of-region). To stamp additional product-specific chips, pass `customTagsFor`: + +```tsx + { + const tags = []; + if (m.modelDetails?.contextWindowTokens && m.modelDetails.contextWindowTokens >= 1_000_000) { + tags.push({ kind: 'long-context', label: 'Long context' }); + } + if (m.byoConnectionLabel?.toLowerCase().includes('on-prem')) { + tags.push({ kind: 'onprem', label: 'On-prem', tooltip: 'Routes to an on-prem connection' }); + } + return tags; + }} + customTagVariants={{ + 'long-context': 'info-mini', + onprem: 'warning-mini', + }} + value={value} + onChange={(m) => setValue(m.modelId)} +/> +``` + +Custom tags render **after** the built-in chips so the picker's canonical signals (Recommended / Preview / lifecycle) always read first. + +`customTagVariants` maps your tag `kind` to an Apollo MUI chip variant. Valid variants: `mini`, `info-mini`, `success-mini`, `warning-mini`, `error-mini`. Unknown kinds fall back to the neutral gray `mini`. + +### 4. Recommended and Preview + +Recommended and Preview travel on the Discovery DTO. Product teams author their Recommended list in their product's Model Hub configuration; the gateway merges it into the Discovery response, so every model arrives with `isRecommended` and `isPreview` set. The picker builds the corresponding groups and chips from those fields: + +```tsx +// The Recommended group + chip come from `model.isRecommended`, +// Preview from `model.isPreview` — no wiring needed. + setValue(m.modelId)} /> +``` + +To promote or retire a model, edit your product's Model Hub configuration — no frontend change needed. + +Resolution order: `recommendedModelIds` prop (test/storybook override) → DTO `isRecommended` → a local heuristic (`UiPathOwned && !preview && !deprecating`) for backends that don't send the field yet. + +### 5. Cost badges + +Cost presentation is a per-product decision, expressed through `customTagsFor` (section 3). The agents product stamps `Basic` / `Standard` / `Premium` chips using the exported `defaultCostTier` classifier: + +```tsx +import { defaultCostTier } from '@uipath/apollo-react/material/components'; + +const COST_LABELS = { basic: 'Basic', standard: 'Standard', premium: 'Premium' }; + + { + const tier = defaultCostTier(m); // null when the DTO has no cost data + return tier ? [{ kind: `cost-${tier}`, label: COST_LABELS[tier] }] : []; + }} +/> +``` + +`defaultCostTier` bins Discovery's `modelDetails.costDetails.inputTokenCost` (USD per million input tokens) at `$1` / `$5`. Copy it, change the thresholds, or key your badges on something else entirely — the picker just renders whatever tags you return. + +### 6. BYO management + +BYO management affordances — edit/delete row actions and the "Use custom model" footer — appear only for users who hold the **`AiTrustLayerByoLlm` license entitlement**, the same signal the Automation Cloud portal uses for the AI Trust Layer's BYO LLM pages. Pass a `requestContext` and the picker runs the check: + +```tsx +const requestContext = React.useMemo(() => ({ + token, + tenantName, // path segment for platform routes + organizationId, // org GUID (entitlement check) + userId, // optional: user-scoped evaluation + // baseUrl: 'https://cloud.uipath.com/acme', // omit when same-origin +}), [token, tenantName, organizationId, userId]); + + router.push('/settings/byo-models')} +/> +``` + +Under the hood: `POST {baseUrl}/lease_/api/entitlements/{organizationId}/entitled` with `{ EntitlementNames: ["AiTrustLayerByoLlm"] }`. The check **fails closed** — affordances stay hidden while loading or on error. + +Products with their own authorization model can pass `canManageByo` (`true`/`false`) instead; when set, no entitlement call is made. Custom row actions go through `slots.optionActions`. + +### 7. Folder scoping + +Set `enableFolders` and the picker fetches the current user's Orchestrator folders (via the same `requestContext`) and renders the toolbar switcher. Your product only decides whether folder scoping applies to its surface: + +```tsx +const [folder, setFolder] = React.useState(null); +const { models } = useDiscoveryModels({ + ...ctx, + folderKey: folder ?? undefined, // omit → backend returns the union +}); + + +``` + +Under the hood: `GET {baseUrl}/{tenantName}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser` (the same call the Automation Cloud portal makes). Folder ids are Orchestrator folder **Keys** (GUIDs) — pass the selected id straight through as `folderKey` on your Discovery re-fetch. + +The picker prepends an "All folders" sentinel automatically; picking it fires `onFolderChange(null)` — re-fetch without a `folderKey` to get the union of all folders the user can see. + +For tests and Storybook, the `folders` prop overrides the internal fetch with a static list. + +### 8. View toggle (Category ⇆ Provider) + +Both views are built-in. Users switch via a pill segmented control on the toolbar. To control the initial view or hide the toggle entirely: + +```tsx + +``` + +In **Category** view, the section order is: Custom Models (BYO) → Recommended → Preview → More models → Deprecating soon → Other. + +In **Provider** view, Custom Models (BYO) comes first, followed by one section per vendor (Anthropic, OpenAI, Google Vertex, AWS Bedrock, …). Within each vendor section, models are ordered by lifecycle: Recommended → Preview → the rest → Deprecating last. + +In both views the BYO section is the only collapsible one — it starts expanded, and the header chevron folds it. + +### 9. Escape hatches (slots) + +Slots are the "I need to do something the picker doesn't natively support" surface. Most products won't need them; reach for a slot only when no prop fits: + +```tsx + ( + { close(); openMyWizard(); }} /> + ), + // Per-row right-aligned actions (overrides default edit/delete). + optionActions: (m) => (m.byoConnectionLabel ? : null), + // Per-row meta column (between chips and actions). + optionMeta: (m) => , + // Custom content inside the trigger, next to the model name. + triggerExtra: (m) => m && , + // Banner above the toolbar (e.g. a tenant-level notice). + popupHeader: () => , + // Inline content to the left of the search field (overrides built-in folder switcher). + searchLeading: () => , + // Content directly below the option list, above any popupFooter. + listFooter: ({ close }) => , + }} +/> +``` + +**Rule of thumb:** if you find yourself reaching for a slot for something everyone wants, propose a new prop instead. + +--- + +## API reference + +### `` props + +| Prop | Type | Description | +| --------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `models` | `DiscoveryModel[]` | The catalog. Required. | +| `value` | `string \| null` | Selected `modelId`. | +| `onChange` | `(model: DiscoveryModel) => void` | Selection callback. Receives the full DTO. | +| `label` | `string` | Label above the trigger. Defaults to a localized "Model". | +| `required` | `boolean` | Marks the field as required (`aria-required` + visual `*`). | +| `placeholder` | `string` | Placeholder when nothing's selected. Defaults to a localized "Select a model". | +| `disabled` | `boolean` | Disables the trigger. | +| `invalid` | `boolean` | Renders the trigger border in error red (`aria-invalid`). | +| `errorText` | `string` | Error message rendered under the trigger (`role="alert"`, linked via `aria-describedby`). | +| `loading` | `boolean` | Shows a spinner in the popup. | +| `error` | `Error \| null` | Renders the error message in the popup body. | +| `variant` | `'searchable' \| 'virtualized'` | Default `'searchable'`; auto-switches to the virtualized renderer above 120 visible options. Pass `'virtualized'` to force it. | +| `groupBy` | `'subscription' \| 'vendor' \| 'flat'` | Initial view. Default `'subscription'`. | +| `allowGroupingChange` | `boolean` | Show the Category ⇆ Provider toggle. Default `true`. | +| `homeRegion` | `string` | User's home region (`'EU'`, `'US'`, …). Triggers the Out-of-region chip when a model's `routingDetails.geography` differs. | +| `recommendedModelIds` | `readonly string[]` | Test/storybook override for the Recommended set. Production reads `model.isRecommended` from the Discovery DTO. | +| `previewModelIds` | `readonly string[]` | Same, for Preview (production: DTO `isPreview`). | +| `filter` | `(m) => boolean` | Per-product filter applied before grouping and search. | +| `friendlyNameFor` | `(m) => string \| null \| undefined` | Map a DTO to a human label. Returns `null` to fall back to `modelName`. | +| `customTagsFor` | `(m) => readonly ModelTag[]` | Product-specific extra chips (see §3; cost badges in §5). | +| `customTagVariants` | `Record` | Apollo MUI chip variant lookup for new tag kinds. | +| `requestContext` | `PlatformRequestContext` | Auth/routing for the picker's built-in platform calls (entitlement check + folder fetch). Pass a memoized object. | +| `canManageByo` | `boolean` | Explicit override for BYO management. When unset and `requestContext` is provided, the picker checks the `AiTrustLayerByoLlm` entitlement. | +| `onUseCustomModel` | `() => void` | Called when the user activates the default footer CTA. Picker closes itself first. | +| `enableFolders` | `boolean` | Turn on folder scoping — the picker fetches the user's Orchestrator folders via `requestContext`. Default `false`. | +| `folders` | `readonly { id; label }[]` | Test/storybook override for the folder list (skips the internal fetch). | +| `folder` | `string \| null` | Selected folder id (Orchestrator folder Key), or `null` for "All folders". | +| `onFolderChange` | `(next: string \| null) => void` | Folder change callback. Re-fetch your catalog with the new `folderKey`. | +| `allFoldersLabel` | `string` | Override the "All folders" sentinel label. | +| `showGroupHeaders` | `boolean` | Default `true`. Set `false` for a flat list (grouping is still applied to ordering). | +| `slots` | `ModelPickerSlots` | Escape hatches. See above. | + +--- + +## Data flow + +The picker is **data-agnostic**. Pass `models` and get `onChange(model)` back. + +Optional Discovery API integration via `useDiscoveryModels({ ctx })`: + +```ts +const { models, loading, error, refetch } = useDiscoveryModels({ + token, + baseUrl, + accountId, + tenantId, + requestingProduct: 'agents', + requestingFeature: 'design-eval-deploy', + folderKey, // optional — scopes BYO results + operationCode, // optional +}); +``` + +Or skip the hook and feed the picker from your existing data layer (SWR, React Query, Redux, etc.). + +--- + +## Accessibility + +The picker implements the WAI-ARIA listbox pattern with keyboard input: + +- **Trigger** is `aria-haspopup="listbox"`, with `aria-controls` pointing at the popup, `aria-expanded`, `aria-invalid`, `aria-required`, and `aria-describedby` linking to the error message. +- **Search input** is `aria-autocomplete="list"`, `aria-controls={listboxId}`, with `aria-activedescendant` updating to the highlighted option as the user navigates with `↑`/`↓` — DOM focus stays on the search. +- **Listbox** has an `aria-label` ("Models" by default, localized). +- **Each option** has a stable id (`{listboxId}-opt-{modelId}`), `role="option"`, and `aria-selected`. +- **Loading / error / empty / result-count** announce via `role="status"` + `aria-live="polite"` and `role="alert"` for errors. +- **Required asterisk** is `aria-hidden` (the input carries `aria-required`). + +Keyboard: + +- `↑` / `↓` — move the active row +- `Enter` — select the active row, close +- `Escape` — close, return focus to the trigger +- `Tab` — moves between trigger, search, and toolbar controls + +--- + +## Theming and dark mode + +The picker reads every color through Apollo's CSS custom properties (`--color-*`) with `Colors.*` constants from `@uipath/apollo-core` as fallbacks. It works **without** an Apollo MUI ThemeProvider — required for portal-shell web component hosts. + +To swap themes, toggle the `--color-*` variable set at the document root (or any ancestor of the picker). Apollo's standard light/dark themes already provide all the variables the picker reads. + +--- + +## Internationalization + +Every user-visible string is keyed under the `modelPicker.*` namespace: + +- Runtime strings resolve through `useSafeLingui()` — the host's `I18nProvider` (or `ApI18nProvider`) supplies translations when mounted, and every descriptor falls back to its English `message` otherwise. The picker never throws in providerless hosts. +- Data-driven labels (group names, tag labels) are defined once in `i18n.ts` as `msg()` descriptors and resolved via `i18n._(descriptor)` at render time. + +The component has its own catalog entry in `packages/apollo-react/lingui.config.ts` (`ap-model-picker/locales/{locale}`). Extract keys with `pnpm i18n:extract` from `packages/apollo-react`; catalogs compile automatically before `build` and `test`. + +--- + +## Performance + +- The picker uses `useMemo` for the catalog → annotated → filtered chain so re-renders without a `models` change skip the work. +- Option rows are memoized (`React.memo` + stable handlers): moving the keyboard highlight or hovering re-renders only the two rows whose `active` flag changed, not the whole list. +- The `searchable` variant auto-switches to the virtualized renderer above 120 visible options, so large catalogs stay smooth without configuration. Pass `variant="virtualized"` to force it. +- The forwarded `ref` points at the trigger button — call `ref.current?.focus()` after a failed form submit to move the user to the field. +- Pass **stable references** for `filter`, `friendlyNameFor`, `customTagsFor` (wrap in `useCallback`) and for `requestContext` (wrap in `useMemo`). The picker re-derives chips / re-fetches when these change identity. + +--- + +## Files + +``` +ap-model-picker/ +├── README.md ← you are here +├── index.ts — public barrel +├── types.ts — Discovery DTO types, tag kinds +├── i18n.ts — central message descriptors +├── utils.ts — deriveModelTags, groupModels, filterModels +├── useModelPickerState.ts — state controller hook +├── useDiscoveryModels.ts — optional Discovery API hook +├── usePlatformAccess.ts — folder list + BYO entitlement hooks +├── ModelPicker.tsx — the picker +├── ModelPicker.test.tsx — unit tests +├── ModelPicker.stories.tsx — Storybook stories +├── ModelTagChip.tsx — semantic Chip wrapper +└── primitives/ + ├── PickerTrigger.tsx — the button + ├── PickerPopup.tsx — Popper + Paper wrapper + ├── PickerSearchInput.tsx — search field with leading/trailing slots + ├── FolderSwitcher.tsx — toolbar folder pill + ├── OptionList.tsx — grouped + virtualized renderers + ├── ModelOptionRow.tsx — one row + └── GroupHeader.tsx — section header +``` + +--- + +## Storybook + +Stories live in `ModelPicker.stories.tsx` and appear under **Apollo React/Material (Maintenance Only)/Components/ModelPicker**. Run `pnpm storybook:dev` from the repo root (port 6007). Key stories: + +- **Default** — baseline picker +- **Virtualized (500+ models)** — performance variant +- **With per-product filter** — `filter` prop in action +- **With friendly names** — `friendlyNameFor` mapping +- **With custom badges** — `customTagsFor` + `customTagVariants` +- **Admin — can manage BYO** — `canManageByo` + `onUseCustomModel` +- **Viewer — read-only BYO** — default, no admin affordances +- **Folder-scoped Custom Models** — built-in folder switcher +- **Recommended from Discovery + cost badges (agents example)** — DTO `isRecommended` + cost chips via `customTagsFor` +- **Routing substitution** — gateway routes traffic; trigger surfaces the redirection +- **Unknown model — graceful fallback** — stored `value` not in catalog +- **Kitchen sink** — every capability turned on at once +- **Dark mode (CSS variables only)** — the picker re-skinned by flipping `--color-*` values on a wrapper, no ThemeProvider diff --git a/packages/apollo-react/src/material/components/ap-model-picker/i18n.ts b/packages/apollo-react/src/material/components/ap-model-picker/i18n.ts new file mode 100644 index 000000000..2ec4fee32 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/i18n.ts @@ -0,0 +1,198 @@ +/** + * Centralized message descriptors for the ModelPicker. + * + * Apollo's lingui setup expects every translatable string to be + * statically extractable. For strings whose identity is known at + * compile time we declare them with `msg()` here once, then resolve + * them at render via `i18n._(descriptor)` — the same catalog pattern + * ap-chat and ap-tool-call use in this package. + * + * This file is reserved for: + * - tag chip labels + tooltips (`deriveModelTags` builds DTOs) + * - group labels + hints (`groupModels` builds DTOs) + * - row + folder switcher defaults that need to be passable to + * non-React utility callers + */ + +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; + +/** + * Minimal translator contract the picker threads through its utils. + * Structurally satisfied both by a real Lingui `I18n` instance and by + * the `useSafeLingui()` fallback, so the picker renders English + * defaults even when the host mounts no `I18nProvider`. + */ +export interface PickerTranslator { + _: (descriptor: MessageDescriptor) => string; +} + +/* ────────────────────────────────────────────────────────────────────── + * Tag chips + * ─────────────────────────────────────────────────────────────────── */ + +export const TAG_LABELS = { + recommended: msg({ + id: 'modelPicker.tag.recommended.label', + message: 'Recommended', + }), + recommendedTooltip: msg({ + id: 'modelPicker.tag.recommended.tooltip', + message: 'Based on evaluation runs for this agent', + }), + preview: msg({ id: 'modelPicker.tag.preview.label', message: 'Preview' }), + custom: msg({ id: 'modelPicker.tag.custom.label', message: 'Custom' }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Groups + * ─────────────────────────────────────────────────────────────────── */ + +export const GROUP_LABELS = { + recommended: msg({ + id: 'modelPicker.group.recommended.label', + message: 'Recommended', + }), + recommendedHint: msg({ + id: 'modelPicker.group.recommended.hint', + message: 'Based on evaluation runs for this agent', + }), + preview: msg({ + id: 'modelPicker.group.preview.label', + message: 'Preview', + }), + previewHint: msg({ + id: 'modelPicker.group.preview.hint', + message: 'Newer models in early access', + }), + more: msg({ id: 'modelPicker.group.more.label', message: 'More models' }), + moreHint: msg({ + id: 'modelPicker.group.more.hint', + message: 'Available, not currently promoted by this product', + }), + deprecating: msg({ + id: 'modelPicker.group.deprecating.label', + message: 'Deprecating soon', + }), + deprecatingHint: msg({ + id: 'modelPicker.group.deprecating.hint', + message: 'Migrate before the usage end date', + }), + byo: msg({ + id: 'modelPicker.group.byo.label', + message: 'Custom Models (BYO)', + }), + byoHint: msg({ + id: 'modelPicker.group.byo.hint', + message: 'Models you brought via your own connections', + }), + other: msg({ id: 'modelPicker.group.other.label', message: 'Other' }), + allModels: msg({ + id: 'modelPicker.group.allModels.label', + message: 'All models', + }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Listbox / accessibility + * ─────────────────────────────────────────────────────────────────── */ + +export const LISTBOX_LABEL = msg({ + id: 'modelPicker.listbox.label', + message: 'Models', +}); + +export const SEARCH_PLACEHOLDER = msg({ + id: 'modelPicker.search.placeholder', + message: 'Search models', +}); + +export const LOADING_LABEL = msg({ + id: 'modelPicker.loading.label', + message: 'Loading models', +}); + +/* ────────────────────────────────────────────────────────────────────── + * Folder switcher + * ─────────────────────────────────────────────────────────────────── */ + +export const FOLDER_SWITCHER = { + allFolders: msg({ + id: 'modelPicker.folderSwitcher.allFolders', + message: 'All folders', + }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Row actions (BYO) + * ─────────────────────────────────────────────────────────────────── */ + +export const ROW_ACTIONS = { + editConnection: msg({ + id: 'modelPicker.row.editConnection', + message: 'Edit connection', + }), + removeConnection: msg({ + id: 'modelPicker.row.removeConnection', + message: 'Remove connection', + }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Footer CTA + * ─────────────────────────────────────────────────────────────────── */ + +export const USE_CUSTOM_MODEL = { + title: msg({ + id: 'modelPicker.useCustomModel.title', + message: 'Use custom model', + }), + subtitle: msg({ + id: 'modelPicker.useCustomModel.subtitle', + message: 'Bring a model from your own connection', + }), + disabledHint: msg({ + id: 'modelPicker.useCustomModel.disabledHint', + message: 'Pass onUseCustomModel to the picker to wire this action.', + }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Group-by toggle + * ─────────────────────────────────────────────────────────────────── */ + +export const GROUP_BY = { + groupAriaLabel: msg({ + id: 'modelPicker.groupBy.ariaLabel', + message: 'Group models by', + }), + category: msg({ id: 'modelPicker.groupBy.category', message: 'Category' }), + provider: msg({ id: 'modelPicker.groupBy.provider', message: 'Provider' }), +} as const; + +/* ────────────────────────────────────────────────────────────────────── + * Empty / placeholder + * ─────────────────────────────────────────────────────────────────── */ + +export const PLACEHOLDER = msg({ + id: 'modelPicker.placeholder.selectAModel', + message: 'Select a model', +}); + +export const LABEL_DEFAULT = msg({ + id: 'modelPicker.label.default', + message: 'Model', +}); + +/* ────────────────────────────────────────────────────────────────────── + * Helpers + * ─────────────────────────────────────────────────────────────────── */ + +/** + * Resolve a descriptor against a translator. Tiny helper to keep call + * sites concise — `i18n._(descriptor)` is the canonical pattern but + * verbose when used repeatedly. + */ +export function tr(i18n: PickerTranslator, descriptor: MessageDescriptor): string { + return i18n._(descriptor); +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/index.ts b/packages/apollo-react/src/material/components/ap-model-picker/index.ts new file mode 100644 index 000000000..f9ad1116c --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/index.ts @@ -0,0 +1,79 @@ +// Main component. `ApModelPicker` is the conventional Ap-prefixed name +// in this package; `ModelPicker` is kept as the canonical export. + +// i18n contract +export type { PickerTranslator } from './i18n'; +export type { + ModelPickerChangeHandler, + ModelPickerProps, + ModelPickerSlotContext, + ModelPickerSlots, + ModelPickerVariant, +} from './ModelPicker'; +export { ModelPicker, ModelPicker as ApModelPicker } from './ModelPicker'; +export type { ModelTagChipProps } from './ModelTagChip'; +// Tag chip +export { ModelTagChip } from './ModelTagChip'; +export type { + FolderSwitcherFolder, + FolderSwitcherProps, +} from './primitives/FolderSwitcher'; +// Primitives — exported so teams can compose their own pickers without forking. +export { FolderSwitcher } from './primitives/FolderSwitcher'; +export type { GroupHeaderProps } from './primitives/GroupHeader'; +export { GroupHeader } from './primitives/GroupHeader'; +export type { ModelOptionRowProps } from './primitives/ModelOptionRow'; +export { defaultRowActions, ModelOptionRow } from './primitives/ModelOptionRow'; +export type { AnnotatedModel, OptionListProps } from './primitives/OptionList'; +export { GroupedOptionList, VirtualOptionList } from './primitives/OptionList'; +export type { PickerPopupProps } from './primitives/PickerPopup'; +export { PickerPopup } from './primitives/PickerPopup'; +export type { PickerSearchInputProps } from './primitives/PickerSearchInput'; +export { PickerSearchInput } from './primitives/PickerSearchInput'; +export type { PickerTriggerProps } from './primitives/PickerTrigger'; +export { PickerTrigger } from './primitives/PickerTrigger'; +// Types +export type { + ByomDetails, + CostTier, + DeprecationDetails, + DiscoveryModel, + DiscoveryRequestContext, + ModelCostDetails, + ModelDetails, + ModelGeography, + ModelGroup, + ModelSubscriptionType, + ModelTag, + ModelTagKind, + ModelVendor, + RoutingDetails, +} from './types'; +export type { UseDiscoveryModelsResult } from './useDiscoveryModels'; +// Data hooks +export { useDiscoveryModels } from './useDiscoveryModels'; +export type { + UseModelPickerStateOptions, + UseModelPickerStateResult, +} from './useModelPickerState'; +// State controller (for teams building custom pickers from the primitives) +export { useModelPickerState } from './useModelPickerState'; +export type { + PlatformRequestContext, + UseCanManageByoResult, + UseUserFoldersResult, +} from './usePlatformAccess'; +export { useCanManageByo, useUserFolders } from './usePlatformAccess'; +export type { + DeriveModelTagsContext, + GroupModelsContext, + GroupStrategy, +} from './utils'; +// Utilities +export { + defaultCostTier, + deriveModelTags, + filterModels, + getSubstitutionTarget, + groupModels, +} from './utils'; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/de.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/de.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/de.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/es-MX.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/es-MX.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/es-MX.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/es.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/es.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/es.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/fr.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/fr.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/fr.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/ja.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/ja.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/ja.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/ko.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/ko.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/ko.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/pt-BR.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/pt-BR.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/pt-BR.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/pt.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/pt.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/pt.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/ru.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/ru.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/ru.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/tr.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/tr.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/tr.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-CN.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-CN.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-CN.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-TW.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-TW.json new file mode 100644 index 000000000..1ecb2baa3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/zh-TW.json @@ -0,0 +1,40 @@ +{ + "modelPicker.count.one": "", + "modelPicker.count.many": "", + "modelPicker.folderSwitcher.allFolders": "", + "modelPicker.group.allModels.label": "", + "modelPicker.group.more.hint": "", + "modelPicker.tag.recommended.tooltip": "", + "modelPicker.group.recommended.hint": "", + "modelPicker.useCustomModel.subtitle": "", + "modelPicker.groupBy.category": "", + "modelPicker.tag.custom.label": "", + "modelPicker.group.byo.label": "", + "modelPicker.tag.deprecating.label": "", + "modelPicker.group.deprecating.label": "", + "modelPicker.row.editConnection": "", + "modelPicker.groupBy.ariaLabel": "", + "modelPicker.loading.label": "", + "modelPicker.group.deprecating.hint": "", + "modelPicker.label.default": "", + "modelPicker.listbox.label": "", + "modelPicker.group.byo.hint": "", + "modelPicker.group.more.label": "", + "modelPicker.group.preview.hint": "", + "modelPicker.group.other.label": "", + "modelPicker.tag.outOfRegion.label": "", + "modelPicker.useCustomModel.disabledHint": "", + "modelPicker.tag.preview.label": "", + "modelPicker.group.preview.label": "", + "modelPicker.groupBy.provider": "", + "modelPicker.tag.recommended.label": "", + "modelPicker.group.recommended.label": "", + "modelPicker.row.removeConnection": "", + "modelPicker.tag.substituted.label": "", + "modelPicker.tag.outOfRegion.tooltip": "", + "modelPicker.search.placeholder": "", + "modelPicker.placeholder.selectAModel": "", + "modelPicker.tag.substituted.tooltip": "", + "modelPicker.useCustomModel.title": "", + "modelPicker.tag.deprecating.tooltip": "" +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.test.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.test.tsx new file mode 100644 index 000000000..f17e82cd3 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { FolderSwitcher } from './FolderSwitcher'; + +const FOLDERS = [ + { id: 'guid-a', label: 'Shared' }, + { id: 'guid-b', label: 'Finance' }, +]; + +function renderSwitcher(ui: React.ReactElement) { + // No I18nProvider: useSafeLingui falls back to English defaults. + return render(ui); +} + +describe('', () => { + it('renders the All folders sentinel when unscoped', () => { + renderSwitcher( {}} />); + expect(screen.getByText('All folders')).toBeInTheDocument(); + }); + + it('falls back to the sentinel label when the value is unknown', () => { + renderSwitcher( {}} />); + expect(screen.getByText('All folders')).toBeInTheDocument(); + }); + + it('opens the menu and emits the picked folder id', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSwitcher(); + + await user.click(screen.getByRole('button', { expanded: false })); + await user.click(screen.getByRole('menuitem', { name: /Finance/ })); + + expect(onChange).toHaveBeenCalledWith('guid-b'); + // Selecting closes the menu. + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); + }); + + it('emits null when the All folders sentinel is picked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderSwitcher(); + + expect(screen.getByText('Shared')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { expanded: false })); + await user.click(screen.getByRole('menuitem', { name: /All folders/ })); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('hides the sentinel when showAllFolders is false', async () => { + const user = userEvent.setup(); + renderSwitcher( + {}} showAllFolders={false} /> + ); + + await user.click(screen.getByRole('button', { expanded: false })); + expect(screen.getAllByRole('menuitem')).toHaveLength(FOLDERS.length); + expect(screen.queryByRole('menuitem', { name: /All folders/ })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx new file mode 100644 index 000000000..45155c569 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx @@ -0,0 +1,190 @@ +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Divider from '@mui/material/Divider'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import { Colors } from '@uipath/apollo-core'; +import React from 'react'; +import { useSafeLingui } from '../../../../i18n'; + +/** + * A single folder the picker can scope to. `id` is opaque to the + * picker — the host re-fetches Discovery with whatever it means. + */ +export interface FolderSwitcherFolder { + id: string; + label: string; +} + +export interface FolderSwitcherProps { + /** Real folders the user can scope to. */ + folders: readonly FolderSwitcherFolder[]; + /** + * Current folder id. Pass `null` for the "All folders" sentinel + * (no `X-UiPath-FolderKey` header on the Discovery request). + */ + value: string | null; + onChange: (next: string | null) => void; + /** Label for the "All folders" sentinel. Default: `'All folders'`. */ + allFoldersLabel?: string; + /** + * Render the "All folders" sentinel. Default: `true`. Set to `false` + * when the host requires the picker to be scoped to a specific + * folder (no tenant-wide BYO view). + */ + showAllFolders?: boolean; +} + +/** + * Toolbar folder switcher. Renders as a pill with a grid/folder + * indicator + label + chevron. Click opens a Popper-anchored menu + * with "All folders" + per-folder items. + * + * Built on Popper (not MUI Select/Menu) because the parent + * `` is itself a Popper — MUI's Menu/Popover absolute + * positioning misbehaves when `disablePortal` plants a menu inside + * another Popper-positioned container. Popper handles this correctly + * because it re-runs placement on every open. + */ +export const FolderSwitcher: React.FC = ({ + folders, + value, + onChange, + allFoldersLabel, + showAllFolders = true, +}) => { + const { _ } = useSafeLingui(); + const resolvedAllFoldersLabel = + allFoldersLabel ?? + _({ + id: 'modelPicker.folderSwitcher.allFolders', + message: 'All folders', + }); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = !!anchorEl; + const current = folders.find((f) => f.id === value); + const isAll = value == null; + + return ( + <> + setAnchorEl(open ? null : e.currentTarget)} + focusRipple + aria-haspopup="listbox" + aria-expanded={open} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + fontSize: 13, + fontWeight: 600, + lineHeight: 1.2, + color: `var(--color-primary, ${Colors.ColorBlue500})`, + backgroundColor: `var(--color-background-raised, ${Colors.ColorWhite})`, + border: '1px solid', + borderColor: `var(--color-border-de-emp, ${Colors.ColorGray300})`, + borderRadius: '8px', + px: 1.25, + py: 0.875, + '&:hover': { + backgroundColor: `var(--color-background-hover, rgba(82, 96, 105, 0.078))`, + }, + }} + > + {isAll ? ( + + ) : ( + + )} + + {isAll ? resolvedAllFoldersLabel : (current?.label ?? resolvedAllFoldersLabel)} + + + + + setAnchorEl(null)}> + + + {showAllFolders && ( + <> + { + onChange(null); + setAnchorEl(null); + }} + sx={{ + fontSize: 13, + gap: 1, + py: 0.875, + borderRadius: '7px', + }} + > + + {resolvedAllFoldersLabel} + + + + )} + {folders.map((f) => ( + { + onChange(f.id); + setAnchorEl(null); + }} + sx={{ fontSize: 13, gap: 1, py: 0.875, borderRadius: '7px' }} + > + + {f.label} + + ))} + + + + + + ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx new file mode 100644 index 000000000..4947b25bf --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx @@ -0,0 +1,180 @@ +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import Typography from '@mui/material/Typography'; +import { Colors } from '@uipath/apollo-core'; +import type React from 'react'; + +export interface GroupHeaderProps { + label: string; + /** + * Descriptive hint about the group. Surfaced as a `title` tooltip on + * the header — inline hints added more chrome than information. + */ + hint?: string; + /** + * Right-aligned model count (e.g. "3 models"). Pass when known; the + * header pluralizes automatically. Omit to suppress. + */ + count?: number; + /** + * Pre-localized "{n} models" / "{n} model" label rendered to the + * right of the title. When provided, overrides the built-in + * (English-only) ternary derived from `count`. + */ + countLabel?: string; + /** Reduces vertical padding for the compact picker. */ + dense?: boolean; + /** + * Whether this is the first header in the list. When true, no + * `border-top` is drawn — that line would double up with the search + * row's existing `border-bottom`. + */ + isFirst?: boolean; + /** + * Leading icon glyph rendered before the label. Used for the BYO + * accordion's shield icon. Falls back to nothing when omitted. + */ + leadingIcon?: React.ReactNode; + /** + * When `true`, the header renders as a button with a trailing + * chevron (rotated based on `collapsed`) and calls `onToggle` on + * click. Used for the BYO accordion at the bottom of the list. + */ + collapsible?: boolean; + /** Current collapsed state (rotates chevron) when `collapsible`. */ + collapsed?: boolean; + /** Click handler; required when `collapsible`. */ + onToggle?: () => void; + /** Optional test id forwarded to the header. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; +} + +/** + * Section header rendered between model groups in the picker dropdown. + * Exportable so teams can render their own grouped layouts. + * + * Two modes: + * - **Static** (default): a label band with optional leading icon + + * trailing count. No interactivity. Used for Recommended / Preview + * / More / Deprecating / per-vendor sections. + * - **Collapsible**: same chrome, but the entire band is a button + * with a trailing chevron that flips on `collapsed`. Used by the + * BYO accordion at the bottom of the Category view. + * + * All colors read through Apollo's CSS custom properties so the + * header dark-modes correctly when used as a web component. + */ +export const GroupHeader: React.FC = ({ + label, + hint, + count, + countLabel, + dense, + isFirst, + leadingIcon, + collapsible, + collapsed, + onToggle, + 'data-testid': dataTestId, +}) => { + const resolvedCountLabel = + countLabel ?? (count != null ? `${count} ${count === 1 ? 'model' : 'models'}` : null); + + const content = ( + <> + {leadingIcon && ( + + {leadingIcon} + + )} + + {label} + + {resolvedCountLabel != null && ( + + {resolvedCountLabel} + + )} + {collapsible && ( + + )} + + ); + + const baseSx = { + width: '100%', + px: dense ? 1.5 : 1.75, + pt: isFirst ? (dense ? 0.75 : 1.5) : dense ? 1.25 : 1.75, + pb: dense ? 0.5 : 0.875, + backgroundColor: `var(--color-background-secondary, ${Colors.ColorGray150})`, + borderTop: isFirst ? 'none' : `1px solid var(--color-border-grid, ${Colors.ColorGray150})`, + display: 'flex', + alignItems: 'center', + gap: 1, + textAlign: 'left' as const, + }; + + if (collapsible) { + return ( + + {content} + + ); + } + return ( + + {content} + + ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/ModelOptionRow.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/ModelOptionRow.tsx new file mode 100644 index 000000000..5909637c9 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/ModelOptionRow.tsx @@ -0,0 +1,379 @@ +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import { Colors, FontFamily } from '@uipath/apollo-core'; +import React from 'react'; +import type { PickerTranslator } from '../i18n'; + +import { ModelTagChip } from '../ModelTagChip'; +import type { DiscoveryModel } from '../types'; +import { type DeriveModelTagsContext, deriveModelTags } from '../utils'; + +export interface ModelOptionRowProps { + model: DiscoveryModel; + /** + * Position of this row in the flat option list. Passed back through + * `onActivate` so the row's handlers stay referentially stable — the + * key to `React.memo` skipping re-renders of untouched rows. + */ + index: number; + active: boolean; + selected: boolean; + /** + * Called with the row's model on click. Pass a stable reference + * (e.g. the `choose` callback from `useModelPickerState`) so + * memoization holds. + */ + onSelect: (model: DiscoveryModel) => void; + /** + * Called with the row's index on pointer-enter (drives the keyboard + * active-row highlight). Pass a stable reference. + */ + onActivate?: (index: number) => void; + /** + * Context forwarded to `deriveModelTags`. Carries `homeRegion`, + * `recommendedModelIds` / `previewModelIds` (Model_hub overrides), + * `costTierFor`, `customTagsFor`, and the Lingui `i18n` instance. + */ + tagContext?: DeriveModelTagsContext; + /** + * Apollo MUI chip variant lookup for tag kinds the design system + * doesn't know about. Forwarded straight to ``. Built-in + * kinds keep their default variant unless explicitly overridden here. + */ + tagVariants?: Record; + /** + * Maps the model to its human label. When it returns a string, the + * row's primary line shows it and the technical id (`modelId`) + * renders as a monospace secondary line — the design's "friendly" + * mode. Return `null`/`undefined` to keep the raw `modelName`. + */ + friendlyNameFor?: (model: DiscoveryModel) => string | null | undefined; + /** + * Right-aligned actions renderer, called with the row's model. When + * omitted, `defaultRowActions` renders edit/delete for BYO models. + * Return `null` to suppress actions for a given row. + */ + renderActions?: (model: DiscoveryModel) => React.ReactNode; + /** + * Extra meta renderer (below the tier chip + context line in the + * right column). Use for cost bars, latency badges, etc. + */ + renderMeta?: (model: DiscoveryModel) => React.ReactNode; + /** + * Reduces row height + font sizes for tight surfaces (chat input + * footers, narrow side panels). The stock picker doesn't use this; + * kept for teams composing their own pickers from primitives. + */ + dense?: boolean; + /** + * Tag kinds to hide from chip rendering. Tags are still derived; this + * only affects display. + */ + hideTagKinds?: readonly string[]; + /** + * Stable DOM id assigned to the row. Used by the search input's + * `aria-activedescendant` so screen readers announce the highlighted + * row while keyboard focus stays on the input. + */ + id?: string; + /** Optional test id forwarded to the row. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; +} + +const FULL_OPTION_HEIGHT = 64; +const DENSE_OPTION_HEIGHT = 44; + +function formatContextWindow(tokens: number | undefined): string | null { + if (tokens == null || tokens <= 0) return null; + if (tokens >= 1_000_000) { + const m = tokens / 1_000_000; + return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M context`; + } + if (tokens >= 1_000) { + const k = Math.round(tokens / 1_000); + return `${k}K context`; + } + return `${tokens} context`; +} + +/** + * One row in the picker dropdown. Exported so teams can render their own + * grouped/ungrouped lists while keeping the styling consistent with the + * stock ``. + * + * Memoized: with stable `onSelect` / `onActivate` / renderer props, only + * the rows whose `active` / `selected` flags actually changed re-render + * when the user moves the highlight — the difference between smooth and + * janky on 200+ model catalogs. + */ +const ModelOptionRowInner: React.FC = ({ + model, + index, + active, + selected, + onSelect, + onActivate, + tagContext, + tagVariants, + friendlyNameFor, + renderActions, + renderMeta, + dense, + hideTagKinds, + id, + 'data-testid': dataTestId, +}) => { + const inlineTags = deriveModelTags(model, tagContext ?? {}).filter( + (t) => !hideTagKinds?.includes(t.kind) + ); + + // Friendly-mode rows show the human label up top and the technical id + // as a monospace secondary line. When no friendly label is supplied, + // the technical name *is* the primary label and the secondary line + // only renders for BYO models (where the connection name is the only + // disambiguator between two models with the same technical id). + const primaryLabel = friendlyNameFor?.(model) ?? null; + const primary = primaryLabel ?? model.modelName; + const usesFriendlyName = !!primaryLabel && primaryLabel !== model.modelId; + const techId = usesFriendlyName ? model.modelId : null; + const minHeight = dense ? DENSE_OPTION_HEIGHT : FULL_OPTION_HEIGHT; + const contextLabel = formatContextWindow(model.modelDetails?.contextWindowTokens); + const rowActions = renderActions + ? renderActions(model) + : defaultRowActions(model, { i18n: tagContext?.i18n }); + const meta = renderMeta?.(model); + + return ( + onSelect(model)} + onMouseEnter={onActivate ? () => onActivate(index) : undefined} + sx={{ + width: '100%', + minHeight, + // 14px horizontal per the design handoff's row spec. + px: dense ? 1.5 : 1.75, + py: dense ? 0.875 : 1.375, + display: 'flex', + alignItems: 'flex-start', + gap: dense ? 1 : 1.5, + justifyContent: 'flex-start', + textAlign: 'left', + position: 'relative', + // Selected state: 3px left accent bar (primary) plus a + // `colorBackgroundSelected` fill across the whole row. Both + // colors read through CSS variables so the row dark-modes + // correctly when used as a web component. + boxShadow: selected + ? `inset 3px 0px 0px var(--color-primary, ${Colors.ColorBlue500})` + : 'none', + backgroundColor: selected + ? `var(--color-background-selected, ${Colors.ColorBlue050})` + : active + ? `var(--color-background-hover, rgba(82, 96, 105, 0.078))` + : 'transparent', + '&:hover': { + backgroundColor: selected + ? `var(--color-background-selected, ${Colors.ColorBlue050})` + : `var(--color-background-hover, rgba(82, 96, 105, 0.078))`, + }, + }} + > + + + + {primary} + + {inlineTags.map((t) => ( + + + + ))} + + {techId && ( + // Friendly-mode secondary line: the canonical technical id in + // monospace so users can still copy/audit the actual model + // string the gateway will call. + + {techId} + + )} + {/* + BYO rows surface the connection name regardless of friendly + mode — two BYO models can share a technical id (`gpt-4o` from + two different connections) and the connection label is the + only disambiguator. + */} + {model.byoConnectionLabel && ( + + {model.byoConnectionLabel} + + )} + + {(contextLabel ?? meta) && ( + // Right column: the context window line with any host-supplied + // meta stacked underneath. Aligns to the end (per design) and + // never shrinks — name truncates first. + e.stopPropagation()} + > + {contextLabel && ( + + {contextLabel} + + )} + {meta} + + )} + {rowActions && ( + e.stopPropagation()} + > + {rowActions} + + )} + + ); +}; + +export const ModelOptionRow = React.memo(ModelOptionRowInner); +ModelOptionRow.displayName = 'ModelOptionRow'; + +/** + * Default row-actions renderer: edit + delete icon buttons for BYO models. + * Exported so consumers can fall back to it when overriding row actions + * selectively (e.g. add a "Set default" action without losing edit/delete). + * + * Admin-gated by default: the picker only calls this when + * `canManageByo` is true. Standalone consumers should gate the call + * themselves before passing the result into `renderActions`. + */ +export function defaultRowActions( + model: DiscoveryModel, + options: { + /** + * Lingui i18n instance. When provided, tooltips localize; when + * omitted, English source strings are used. + */ + i18n?: PickerTranslator; + } = {} +): React.ReactNode { + const isByo = + model.modelSubscriptionType === 'BYOMAdded' || + model.modelSubscriptionType === 'BYOMReplacedAlternative' || + model.modelSubscriptionType === 'BYOMReplacedLikeForLike'; + if (!isByo) return null; + const editTitle = options.i18n + ? options.i18n._({ + id: 'modelPicker.row.editConnection', + message: 'Edit connection', + }) + : 'Edit connection'; + const removeTitle = options.i18n + ? options.i18n._({ + id: 'modelPicker.row.removeConnection', + message: 'Remove connection', + }) + : 'Remove connection'; + return ( + <> + + + + + + + + + + + + ); +} + +export { FULL_OPTION_HEIGHT, DENSE_OPTION_HEIGHT }; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.test.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.test.tsx new file mode 100644 index 000000000..0d86467c9 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { defaultRowActions } from './ModelOptionRow'; +import type { AnnotatedModel } from './OptionList'; +import { GroupedOptionList, optionDomId, VirtualOptionList } from './OptionList'; + +// jsdom reports 0×0 elements, so the real virtualizer would compute an +// empty window and render nothing. Virtualize "everything" instead — +// these tests cover VirtualOptionList's own rendering, not the library. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (opts: { count: number; estimateSize: (i: number) => number }) => { + const items = Array.from({ length: opts.count }, (_, index) => ({ + index, + key: index, + start: index * 56, + size: opts.estimateSize(index), + })); + return { + getVirtualItems: () => items, + getTotalSize: () => items.reduce((sum, it) => sum + it.size, 0), + scrollToIndex: () => {}, + }; + }, +})); + +function opt(overrides: Partial): AnnotatedModel { + return { + modelId: 'm-1', + modelName: 'm-1', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + groupKey: 'more', + groupLabel: 'More models', + ...overrides, + }; +} + +const OPTIONS: AnnotatedModel[] = [ + opt({ + modelId: 'byo-1', + modelName: 'byo-model', + modelSubscriptionType: 'BYOMAdded', + byoConnectionLabel: 'Conn', + groupKey: 'byo', + groupLabel: 'Custom Models (BYO)', + }), + opt({ + modelId: 'rec-1', + modelName: 'model-rec', + groupKey: 'recommended', + groupLabel: 'Recommended', + modelDetails: { contextWindowTokens: 1_000_000 }, + }), + opt({ + modelId: 'more-1', + modelName: 'model-more-1', + modelDetails: { contextWindowTokens: 400_000 }, + }), + opt({ + modelId: 'more-2', + modelName: 'model-more-2', + modelDetails: { contextWindowTokens: 500 }, + }), +]; + +function renderList(ui: React.ReactElement) { + // No I18nProvider: useSafeLingui falls back to English defaults. + return render(ui); +} + +const noopProps = { + id: 'listbox', + activeIndex: -1, + setActiveIndex: () => {}, + selectedId: null, +}; + +describe('', () => { + it('renders group headers and option rows', () => { + renderList( {}} />); + expect(screen.getByRole('listbox', { name: 'Models' })).toBeInTheDocument(); + expect(screen.getByText('More models')).toBeInTheDocument(); + expect(screen.getByText('model-rec')).toBeInTheDocument(); + expect(screen.getByText('model-more-2')).toBeInTheDocument(); + }); + + it('renders the header of a collapsed group but not its rows', () => { + renderList( + {}} + collapsedGroups={new Set(['byo'])} + /> + ); + expect(screen.getByText('Custom Models (BYO)')).toBeInTheDocument(); + expect(screen.queryByText('byo-model')).not.toBeInTheDocument(); + }); + + it('toggles the BYO group when its header is clicked', async () => { + const user = userEvent.setup(); + const onGroupToggle = vi.fn(); + renderList( + {}} + onGroupToggle={onGroupToggle} + /> + ); + await user.click(screen.getByText('Custom Models (BYO)')); + expect(onGroupToggle).toHaveBeenCalledWith('byo'); + }); + + it('selects the clicked row', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + renderList(); + await user.click(screen.getByText('model-more-1')); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ modelId: 'more-1' })); + }); +}); + +describe('', () => { + it('derives per-group counts when groupCounts is not supplied', () => { + renderList( {}} />); + // 'More models' holds two options; the other groups hold one each. + expect(screen.getByText('2 models')).toBeInTheDocument(); + expect(screen.getAllByText('1 model')).toHaveLength(2); + }); + + it('toggles the BYO group when its header is clicked', async () => { + const user = userEvent.setup(); + const onGroupToggle = vi.fn(); + renderList( + {}} + onGroupToggle={onGroupToggle} + /> + ); + await user.click(screen.getByText('Custom Models (BYO)')); + expect(onGroupToggle).toHaveBeenCalledWith('byo'); + }); +}); + +describe('defaultRowActions', () => { + it('returns null for hosted (non-BYO) models', () => { + expect(defaultRowActions(opt({}))).toBeNull(); + }); + + it('renders edit + remove actions for BYO models', () => { + render(<>{defaultRowActions(opt({ modelSubscriptionType: 'BYOMAdded', groupKey: 'byo' }))}); + expect(screen.getByRole('button', { name: 'Edit connection' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove connection' })).toBeInTheDocument(); + }); +}); + +describe('optionDomId', () => { + it('sanitizes non-word characters so querySelector stays safe', () => { + expect(optionDomId('list', 'anthropic.claude:v1/0')).toBe('list-opt-anthropic-claude-v1-0'); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.tsx new file mode 100644 index 000000000..300d09a58 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/OptionList.tsx @@ -0,0 +1,400 @@ +import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; +import Box from '@mui/material/Box'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import React, { useEffect, useMemo, useRef } from 'react'; +import type { PickerTranslator } from '../i18n'; + +import { GROUP_LABELS } from '../i18n'; +import type { DiscoveryModel } from '../types'; +import type { DeriveModelTagsContext } from '../utils'; +import { GroupHeader } from './GroupHeader'; +import { DENSE_OPTION_HEIGHT, FULL_OPTION_HEIGHT, ModelOptionRow } from './ModelOptionRow'; + +export interface AnnotatedModel extends DiscoveryModel { + groupKey: string; + groupLabel: string; +} + +export interface OptionListProps { + id: string; + options: AnnotatedModel[]; + activeIndex: number; + setActiveIndex: (i: number) => void; + selectedId: string | null; + onSelect: (m: DiscoveryModel) => void; + /** + * Forwarded to every row's `deriveModelTags` call. Use for + * `recommendedModelIds`, `previewModelIds`, `costTierFor`, + * `customTagsFor`, `homeRegion`, and the Lingui `i18n` instance. + */ + tagContext?: DeriveModelTagsContext; + /** + * Apollo MUI chip variant lookup for tag kinds the design system + * doesn't know about. Forwarded to every row's ``. + */ + tagVariants?: Record; + /** + * Maps a model to its friendly label. When set, the row's primary + * line shows the returned string and the technical id renders as a + * monospace secondary line. Returning `null`/`undefined` keeps the + * raw `modelName`. + */ + friendlyNameFor?: (model: DiscoveryModel) => string | null | undefined; + /** + * Group counts used by GroupHeader to render the right-aligned count + * (e.g. "3 models"). Keyed by `groupKey`. When omitted, the + * counter is derived from the visible options. + */ + groupCounts?: Record; + /** Override the BYO edit/delete row actions. Return null to suppress. */ + renderRowActions?: (m: DiscoveryModel) => React.ReactNode; + /** Per-row meta column (cost, context window, etc.). */ + renderRowMeta?: (m: DiscoveryModel) => React.ReactNode; + /** Reduced row + header sizes. */ + dense?: boolean; + /** Maximum height of the scroll region. Default 362 (design spec). */ + maxHeight?: number; + /** + * Suppress group header rows. Options are still grouped + ordered as + * usual; only the visual section labels are hidden. Use for compact / + * inline pickers where headers add too much chrome. + */ + hideGroupHeaders?: boolean; + /** + * Tag kinds to hide from chips on each row. Tags are still derived by + * `deriveModelTags`; this only affects rendering. + */ + hideTagKinds?: readonly string[]; + /** + * Accessible name for the listbox. Defaults to "Models". Screen + * readers announce this when focus enters the picker so users know + * what they're selecting from. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'aria-label'?: string; + /** + * Set of group keys currently rendered as collapsed (header visible, + * options hidden). Today only the BYO group ('byo') is supported as + * collapsible; the parent ModelPicker owns this state. + */ + collapsedGroups?: ReadonlySet; + /** Click handler for collapsible group headers. */ + onGroupToggle?: (groupKey: string) => void; +} + +const BYO_GROUP_KEY = 'byo'; + +// Design-spec scroll height for the dropdown's list region. +const DEFAULT_LIST_MAX_HEIGHT = 362; + +/** + * Per-group decorations rendered into the header (leading icon). + * Today only BYO carries a glyph (shield); other groups stay text-only. + */ +function groupLeadingIcon(groupKey: string): React.ReactNode { + if (groupKey === BYO_GROUP_KEY) { + return ; + } + return null; +} + +const GROUP_HINT_DESCRIPTORS = { + recommended: GROUP_LABELS.recommendedHint, + preview: GROUP_LABELS.previewHint, + byo: GROUP_LABELS.byoHint, + more: GROUP_LABELS.moreHint, + deprecating: GROUP_LABELS.deprecatingHint, +} as const; + +/** Localized tooltip hint for a built-in group; unknown keys get none. */ +function groupHint(groupKey: string, i18n?: PickerTranslator): string | undefined { + const desc = GROUP_HINT_DESCRIPTORS[groupKey as keyof typeof GROUP_HINT_DESCRIPTORS]; + if (!desc) return undefined; + return i18n ? i18n._(desc) : desc.message; +} + +/** Localized "{n} model(s)" label; undefined when there's no i18n so + * GroupHeader falls back to its English ternary via `count`. */ +function countLabel(n: number | undefined, i18n?: PickerTranslator): string | undefined { + if (n == null || !i18n) return undefined; + return n === 1 + ? i18n._({ + id: 'modelPicker.count.one', + message: '{n} model', + values: { n }, + }) + : i18n._({ + id: 'modelPicker.count.many', + message: '{n} models', + values: { n }, + }); +} + +/** + * Build a stable DOM id for a given option, derived from the listbox + * id + the model's id. Used by the search input's + * `aria-activedescendant` so screen readers can announce the active + * option without moving DOM focus. + */ +export function optionDomId(listboxId: string, modelId: string): string { + // modelIds in production are dotted (e.g. anthropic.claude-...). DOM + // ids are valid with dots — but consumers sometimes target them via + // querySelector, where the dot is parsed as a class separator. Strip + // the risk by replacing non-word characters with `-`. + const safe = modelId.replace(/[^A-Za-z0-9_-]/g, '-'); + return `${listboxId}-opt-${safe}`; +} + +export const GroupedOptionList: React.FC = ({ + id, + options, + activeIndex, + setActiveIndex, + selectedId, + onSelect, + tagContext, + tagVariants, + friendlyNameFor, + groupCounts, + renderRowActions, + renderRowMeta, + dense, + maxHeight = DEFAULT_LIST_MAX_HEIGHT, + hideGroupHeaders, + hideTagKinds, + collapsedGroups, + onGroupToggle, + 'aria-label': ariaLabel = 'Models', +}) => { + let lastGroupKey: string | undefined; + let headerCount = 0; + // Compute counts up front when the parent didn't supply them — useful + // for standalone primitive composition. + const derivedCounts = useMemo(() => { + if (groupCounts) return groupCounts; + const out: Record = {}; + for (const o of options) { + out[o.groupKey] = (out[o.groupKey] ?? 0) + 1; + } + return out; + }, [groupCounts, options]); + + return ( + + {options.map((opt, i) => { + const showHeader = !hideGroupHeaders && opt.groupKey !== lastGroupKey; + const isFirstHeader = showHeader && headerCount === 0; + if (showHeader) headerCount += 1; + lastGroupKey = opt.groupKey; + // BYO is the only currently collapsible group. The parent owns + // the open/closed state via `collapsedGroups`; we just consult + // it here to skip rendering rows + flip the header chevron. + const isCollapsible = opt.groupKey === BYO_GROUP_KEY; + const isCollapsed = isCollapsible && (collapsedGroups?.has(opt.groupKey) ?? false); + return ( + + {showHeader && ( + onGroupToggle?.(opt.groupKey) : undefined} + /> + )} + {!isCollapsed && ( + + )} + + ); + })} + + ); +}; + +export const VirtualOptionList: React.FC = ({ + id, + options, + activeIndex, + setActiveIndex, + selectedId, + onSelect, + tagContext, + tagVariants, + friendlyNameFor, + groupCounts, + renderRowActions, + renderRowMeta, + dense, + maxHeight = DEFAULT_LIST_MAX_HEIGHT, + hideGroupHeaders, + hideTagKinds, + collapsedGroups, + onGroupToggle, + 'aria-label': ariaLabel = 'Models', +}) => { + type Row = + | { + kind: 'header'; + key: string; + groupKey: string; + label: string; + isFirst: boolean; + count: number; + } + | { + kind: 'option'; + key: string; + model: AnnotatedModel; + index: number; + }; + + const rows = useMemo(() => { + const out: Row[] = []; + let lastGroupKey: string | undefined; + let headerCount = 0; + const counts: Record = groupCounts ?? {}; + if (!groupCounts) { + for (const o of options) counts[o.groupKey] = (counts[o.groupKey] ?? 0) + 1; + } + options.forEach((opt, i) => { + const collapsed = collapsedGroups?.has(opt.groupKey) ?? false; + if (!hideGroupHeaders && opt.groupKey !== lastGroupKey) { + out.push({ + kind: 'header', + key: `h-${opt.groupKey}`, + groupKey: opt.groupKey, + label: opt.groupLabel, + isFirst: headerCount === 0, + count: counts[opt.groupKey] ?? 0, + }); + headerCount += 1; + } + lastGroupKey = opt.groupKey; + if (!collapsed) { + out.push({ kind: 'option', key: opt.modelId, model: opt, index: i }); + } + }); + return out; + }, [options, hideGroupHeaders, groupCounts, collapsedGroups]); + + const headerHeight = dense ? 32 : 48; + const optionHeight = dense ? DENSE_OPTION_HEIGHT : FULL_OPTION_HEIGHT; + + const parentRef = useRef(null); + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: (i) => (rows[i]?.kind === 'header' ? headerHeight : optionHeight), + overscan: 10, + }); + + useEffect(() => { + const rowIdx = rows.findIndex((r) => r.kind === 'option' && r.index === activeIndex); + if (rowIdx >= 0) virtualizer.scrollToIndex(rowIdx, { align: 'auto' }); + }, [activeIndex, rows, virtualizer]); + + return ( + + + {virtualizer.getVirtualItems().map((vi) => { + const row = rows[vi.index]; + // `rows` length is the source of truth for virtualizer.count, + // so `vi.index` is always in range — but `noUncheckedIndexedAccess` + // can't prove that. Guard once at the top of the render + // function rather than every property access below. + if (!row) return null; + const baseStyle: React.CSSProperties = { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + transform: `translateY(${vi.start}px)`, + }; + if (row.kind === 'header') { + const isCollapsible = row.groupKey === BYO_GROUP_KEY; + const isCollapsed = isCollapsible && (collapsedGroups?.has(row.groupKey) ?? false); + return ( +
+ onGroupToggle?.(row.groupKey) : undefined} + /> +
+ ); + } + return ( +
+ +
+ ); + })} +
+
+ ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerPopup.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerPopup.tsx new file mode 100644 index 000000000..264a8b1af --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerPopup.tsx @@ -0,0 +1,98 @@ +import Box from '@mui/material/Box'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import { Colors } from '@uipath/apollo-core'; +import type React from 'react'; + +export interface PickerPopupProps { + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; + /** Optional fixed width (defaults to anchor width). */ + width?: number | string; + placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; + /** z-index for the popper. Defaults to 1400 so the popup sits above the + * page and Apollo's app shell, but below MUI Dialogs (which use 1500). */ + zIndex?: number; + /** Renders above the listbox (e.g. search input). */ + header?: React.ReactNode; + /** Renders below the listbox (e.g. effort picker, secondary controls). */ + footer?: React.ReactNode; + /** Optional className forwarded to the inner Paper for host overrides. */ + className?: string; + /** Test id forwarded to the inner Paper. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; + children: React.ReactNode; +} + +/** + * Popper + Paper wrapper used by ModelPicker. Provides + * the consistent dropdown chrome: shadow, border, click-outside-to-close. + * Header/footer are slots so consumers can compose pickers without rewriting + * the popup structure. + * + * All colors read through CSS custom properties (with `Colors.*` + * fallbacks) so the picker dark-modes correctly when consumed as a + * web component without an Apollo MUI ThemeProvider. + */ +export const PickerPopup: React.FC = ({ + open, + anchorEl, + onClose, + width, + placement = 'bottom-start', + zIndex = 1400, + header, + footer, + className, + 'data-testid': dataTestId, + children, +}) => ( + + + + {header} + {children} + {footer && ( + + {footer} + + )} + + + +); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerSearchInput.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerSearchInput.tsx new file mode 100644 index 000000000..ea8110478 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerSearchInput.tsx @@ -0,0 +1,138 @@ +import SearchIcon from '@mui/icons-material/Search'; +import Box from '@mui/material/Box'; +import InputBase from '@mui/material/InputBase'; +import { Colors } from '@uipath/apollo-core'; +import type React from 'react'; +import { useSafeLingui } from '../../../../i18n'; + +export interface PickerSearchInputProps { + value: string; + onChange: (next: string) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + placeholder?: string; + /** Accessible name for the input. Defaults to the placeholder. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'aria-label'?: string; + /** + * Pointer to the currently active option's element id. Forwarded to + * the input as `aria-activedescendant` so screen readers announce the + * highlighted row even though DOM focus stays on the input. + */ + activeDescendantId?: string; + inputRef?: React.Ref; + listboxId?: string; + dense?: boolean; + /** + * Rendered to the left of the search icon, on the same row. Use for an + * inline folder picker / scope chip so it shares chrome with the search + * field instead of sitting in a separate banner above it. + */ + leading?: React.ReactNode; + /** + * Rendered to the right of the input field, on the same row. Use for + * tiny end-aligned controls like a view-toggle icon button or a clear + * button. Borders match the divider used by `leading`. + */ + trailing?: React.ReactNode; + /** Optional className forwarded to the outer wrapper for host overrides. */ + className?: string; + /** Test id forwarded to the input element. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; +} + +export const PickerSearchInput: React.FC = ({ + value, + onChange, + onKeyDown, + placeholder, + 'aria-label': ariaLabel, + activeDescendantId, + inputRef, + listboxId, + dense, + leading, + trailing, + className, + 'data-testid': dataTestId, +}) => { + const { _ } = useSafeLingui(); + const resolvedPlaceholder = + placeholder ?? _({ id: 'modelPicker.search.placeholder', message: 'Search models' }); + return ( + // Toolbar row per the design: three distinct controls sit side by + // side — [folder pill] [bordered search box] [group-by pills] — + // each with its own chrome, on a 12px-padded band above the list. + + {leading && ( + {leading} + )} + + + onChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={resolvedPlaceholder} + sx={{ + flex: 1, + fontSize: dense ? 13 : 13.5, + color: `var(--color-foreground, ${Colors.ColorGray850})`, + '& .MuiInputBase-input::placeholder': { + color: `var(--color-foreground-disable, ${Colors.ColorGray500})`, + opacity: 1, + }, + }} + inputProps={{ + role: 'combobox', + 'aria-label': ariaLabel ?? resolvedPlaceholder, + 'aria-controls': listboxId, + 'aria-autocomplete': 'list', + 'aria-activedescendant': activeDescendantId, + 'aria-expanded': true, + 'data-testid': dataTestId, + }} + /> + + {trailing && ( + {trailing} + )} + + ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerTrigger.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerTrigger.tsx new file mode 100644 index 000000000..5377446db --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/PickerTrigger.tsx @@ -0,0 +1,238 @@ +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import Typography from '@mui/material/Typography'; +import { Colors } from '@uipath/apollo-core'; +import type React from 'react'; +import { useSafeLingui } from '../../../../i18n'; + +import { ModelTagChip } from '../ModelTagChip'; +import type { DiscoveryModel } from '../types'; +import { type DeriveModelTagsContext, deriveModelTags } from '../utils'; + +export interface PickerTriggerProps { + id: string; + selected: DiscoveryModel | null; + /** + * Raw stored value that didn't resolve against `models`. When set, + * the trigger renders the id verbatim in error-red with the trigger + * border switched to error red — so a stale config doesn't look + * identical to "nothing selected". `null` for the normal case. + */ + unknownValue?: string | null; + placeholder?: string; + disabled?: boolean; + open: boolean; + invalid?: boolean; + /** + * Context forwarded to `deriveModelTags` for the selected model's + * chips. Carries the Lingui `i18n` instance, `homeRegion`, + * test overrides, and `customTagsFor`. + */ + tagContext?: DeriveModelTagsContext; + /** + * Apollo MUI chip variant lookup for tag kinds the design system + * doesn't know about. Forwarded straight to ``. + */ + tagVariants?: Record; + /** + * When set, replaces `selected.modelName` as the trigger's primary + * label. Mirrors the row's friendly-mode behavior so trigger + rows + * stay in sync. + */ + primaryLabel?: string | null; + /** Extra content rendered to the right of the model name, before the caret. */ + extra?: React.ReactNode; + onClick: () => void; + triggerRef?: React.Ref; + /** + * Tag kinds to hide from the trigger's selected-model chips. Used by + * the compact picker to keep the trigger uncluttered. + */ + hideTagKinds?: readonly string[]; + /** + * ID of the popup's listbox. Used to set `aria-controls` so screen + * readers know which element the trigger opens. + */ + controlsId?: string; + /** + * ID of an external error message to associate with the trigger. + * Forwarded as `aria-describedby` so screen readers announce the + * error alongside the field. + */ + describedById?: string; + /** Mark the field as required. Forwarded as `aria-required`. */ + required?: boolean; + /** Optional className forwarded to the trigger element. */ + className?: string; + /** Test id forwarded to the trigger element. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string; +} + +export const PickerTrigger: React.FC = ({ + id, + selected, + unknownValue, + placeholder = 'Select a model', + disabled, + open, + invalid, + tagContext, + tagVariants, + primaryLabel, + extra, + onClick, + triggerRef, + hideTagKinds, + controlsId, + describedById, + required, + className, + 'data-testid': dataTestId, +}) => { + const { _ } = useSafeLingui(); + const effectiveCtx: DeriveModelTagsContext = tagContext ?? { i18n: { _ } }; + const inlineTags = selected + ? deriveModelTags(selected, effectiveCtx).filter((t) => !hideTagKinds?.includes(t.kind)) + : []; + const primary = selected ? (primaryLabel ?? selected.modelName) : null; + + return ( + + + + {selected ? ( + <> + + {primary} + + {inlineTags.map((t) => ( + + + + ))} + + ) : unknownValue ? ( + + {unknownValue} + + ) : ( + + {placeholder} + + )} + + {extra && ( + e.stopPropagation()} + > + {extra} + + )} + + + + ); +}; diff --git a/packages/apollo-react/src/material/components/ap-model-picker/types.ts b/packages/apollo-react/src/material/components/ap-model-picker/types.ts new file mode 100644 index 000000000..730efee59 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/types.ts @@ -0,0 +1,190 @@ +/** + * Mirrors the LLM Gateway `ModelDiscoveryResponse` DTO returned by + * `GET /api/discovery` (UiPath.LLMGateway.Web/Discovery/ModelDiscoveryResponse.cs). + * + * Kept intentionally permissive (string unions, optional fields) so the + * picker survives backend additions without a coordinated release. + */ + +export type ModelVendor = + | 'OpenAi' + | 'AzureOpenAi' + | 'AnthropicClaude' + | 'AwsBedrock' + | 'VertexAi' + | 'Custom' + | string; + +export type ModelSubscriptionType = + | 'UiPathOwned' + | 'BYOMReplacedLikeForLike' + | 'BYOMReplacedAlternative' + | 'BYOMAdded' + | string; + +export type ModelGeography = + | 'EU' + | 'CA' + | 'US' + | 'SI' + | 'JA' + | 'AU' + | 'IN' + | 'UK' + | 'CH' + | 'UAE' + | 'SK' + | 'GLOBAL' + | string; + +export interface DeprecationDetails { + usageEndDate?: string; + replacedBy?: string; +} + +export interface ByomDetails { + availableOperationCodes?: string[]; + integrationServiceConnectionId?: string; + defaultModel?: string; + customFieldMappings?: Record | null; +} + +export interface ModelCostDetails { + inputTokenCost?: number; + outputTokenCost?: number; + currency?: string; +} + +export interface ModelDetails { + maxOutputTokens?: number; + contextWindowTokens?: number; + costDetails?: ModelCostDetails; + shouldUseMaxCompletionTokens?: boolean; + shouldSkipParallelToolCalls?: boolean; + shouldSkipTemperature?: boolean; + shouldSkipTopP?: boolean; + shouldUseResponseApi?: boolean; +} + +export interface RoutingDetails { + geography?: ModelGeography; + model?: string; +} + +export interface DiscoveryModel { + modelId: string; + modelName: string; + effectiveModel?: string | null; + vendor: ModelVendor; + modelFamily?: string; + apiFlavor?: string; + modelSubscriptionType?: ModelSubscriptionType; + /** + * Whether the product team has promoted this model. Authored in + * `Model_hub/.yaml` in gitops-centralized-cluster and merged + * into the Discovery response server-side — products should NOT + * fetch Model_hub themselves. `undefined` means the backend hasn't + * rolled the field out yet; the picker then falls back to a local + * heuristic (`UiPathOwned && !preview && !deprecating`). + */ + isRecommended?: boolean; + isPreview?: boolean; + deprecationDetails?: DeprecationDetails | null; + byomDetails?: ByomDetails | null; + modelDetails?: ModelDetails; + routingDetails?: RoutingDetails | null; + /** + * Optional connection metadata. Discovery doesn't surface this directly today + * but BYO callers can hydrate it from `/api/byom` for richer rendering. + */ + byoConnectionLabel?: string; +} + +/** + * Tags rendered as chips next to a model. Derived locally from the DTO + * (`deriveModelTags`) — the API does not return chip strings. + * + * Open-ended on purpose: products can mint their own kinds via + * `customTagsFor` (e.g. `'multimodal'`, `'on-prem'`). Built-in kinds + * get an Apollo MUI chip variant out of the box; unknown kinds fall + * back to the neutral gray `mini` chip unless overridden via + * `ModelPickerProps.customTagVariants` or `ModelTag.variant`. + */ +export type ModelTagKind = + | 'recommended' + | 'preview' + | 'deprecating' + | 'substituted' + | 'custom' + | 'out-of-region' + | 'thinking' + | 'cost-basic' + | 'cost-standard' + | 'cost-premium' + | string; + +/** + * Categorical cost tier. NOT a built-in picker signal — cost chips are + * a product decision, stamped via `customTagsFor`. The picker ships + * `defaultCostTier` purely as an example classifier (used by the + * agents product) that bins Discovery's + * `modelDetails.costDetails.inputTokenCost`. + */ +export type CostTier = 'basic' | 'standard' | 'premium'; + +export interface ModelTag { + kind: ModelTagKind; + label: string; + /** Optional tooltip shown on hover. */ + tooltip?: string; + /** + * Apollo MUI chip variant class to render this tag with. Built-in + * kinds resolve to a default variant automatically (e.g. + * `recommended` → `success-mini`). Use this for product-specific + * kinds, or to override the default on a built-in kind for a single + * tag. Falls back to `'mini'` (neutral gray) when omitted. + */ + variant?: string; +} + +/** + * One section in the picker dropdown. The picker groups models by + * subscription type by default; consumers can override via `groupBy`. + */ +export interface ModelGroup { + key: string; + label: string; + hint?: string; + models: DiscoveryModel[]; +} + +/** + * Headers required by the Discovery endpoint. Caller controls these + * because they're tenant-/product-specific and shouldn't be hardcoded + * in a design system component. + */ +export interface DiscoveryRequestContext { + /** Bearer token (no `Bearer ` prefix). */ + token: string; + /** Override the gateway base URL (defaults to same-origin). */ + baseUrl?: string; + /** X-UiPath-Internal-AccountId */ + accountId: string; + /** X-UiPath-Internal-TenantId */ + tenantId: string; + /** X-UiPath-LlmGateway-RequestingProduct */ + requestingProduct: string; + /** X-UiPath-LlmGateway-RequestingFeature */ + requestingFeature: string; + /** X-UiPath-LlmGateway-OperationCode (optional, filters BYO configs). */ + operationCode?: string; + /** + * X-UiPath-FolderKey — sent when the picker is scoped to a specific + * folder. Gated behind a BYOM feature flag in the host app. + */ + folderKey?: string; + /** X-UiPath-LlmGateway-FromDedicatedCloud */ + fromDedicatedCloud?: boolean; + /** Default true. When false, returns models filtered out by governance/region too. */ + onlyAvailableModels?: boolean; +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/useDiscoveryModels.ts b/packages/apollo-react/src/material/components/ap-model-picker/useDiscoveryModels.ts new file mode 100644 index 000000000..e41798766 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/useDiscoveryModels.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { DiscoveryModel, DiscoveryRequestContext } from './types'; + +export interface UseDiscoveryModelsResult { + models: DiscoveryModel[]; + loading: boolean; + error: Error | null; + refetch: () => void; +} + +/** + * Calls `GET /api/discovery` on the LLM Gateway. The endpoint is + * `[Authorize(Policies.S2S)]` in the gateway today; some hosts proxy it + * behind their own user-token route — pass `baseUrl` to point there. + * + * The hook is intentionally minimal (fetch + state). Hosts that already + * use SWR/React Query should skip this and feed the picker via the + * `models` prop directly. + */ +export function useDiscoveryModels(ctx: DiscoveryRequestContext | null): UseDiscoveryModelsResult { + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const fetchModels = useCallback(async () => { + if (!ctx) return; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(null); + + const base = (ctx.baseUrl ?? '').replace(/\/$/, ''); + const params = new URLSearchParams(); + if (ctx.onlyAvailableModels === false) { + params.set('onlyAvailableModels', 'false'); + } + const qs = params.toString(); + const url = `${base}/api/discovery${qs ? `?${qs}` : ''}`; + + const headers: Record = { + Authorization: `Bearer ${ctx.token}`, + Accept: 'application/json', + 'X-UiPath-Internal-AccountId': ctx.accountId, + 'X-UiPath-Internal-TenantId': ctx.tenantId, + 'X-UiPath-LlmGateway-RequestingProduct': ctx.requestingProduct, + 'X-UiPath-LlmGateway-RequestingFeature': ctx.requestingFeature, + }; + if (ctx.operationCode) { + headers['X-UiPath-LlmGateway-OperationCode'] = ctx.operationCode; + } + if (ctx.folderKey) { + headers['X-UiPath-FolderKey'] = ctx.folderKey; + } + if (ctx.fromDedicatedCloud) { + headers['X-UiPath-LlmGateway-FromDedicatedCloud'] = 'true'; + } + + try { + const res = await fetch(url, { headers, signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`Discovery API ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { models?: DiscoveryModel[] } | DiscoveryModel[]; + const list = Array.isArray(data) ? data : (data.models ?? []); + if (!ctrl.signal.aborted) { + setModels(normalizeKeys(list)); + } + } catch (err: unknown) { + if ((err as { name?: string })?.name === 'AbortError') return; + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (!ctrl.signal.aborted) setLoading(false); + } + }, [ctx]); + + useEffect(() => { + fetchModels(); + return () => abortRef.current?.abort(); + }, [fetchModels]); + + return { models, loading, error, refetch: fetchModels }; +} + +/** + * The gateway emits PascalCase property names by default but the + * Newtonsoft serializer in some product instances is configured for + * camelCase. Normalize so consumers can rely on camelCase regardless. + */ +function normalizeKeys(list: unknown[]): DiscoveryModel[] { + return list.map((raw) => { + if (!raw || typeof raw !== 'object') return raw as DiscoveryModel; + const obj = raw as Record; + const camel: Record = {}; + for (const [k, v] of Object.entries(obj)) { + camel[k.charAt(0).toLowerCase() + k.slice(1)] = v; + } + return camel as unknown as DiscoveryModel; + }); +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts b/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts new file mode 100644 index 000000000..418403d82 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PickerTranslator } from './i18n'; + +import type { AnnotatedModel } from './primitives/OptionList'; +import type { DiscoveryModel } from './types'; +import { filterModels, type GroupStrategy, groupModels } from './utils'; + +export interface UseModelPickerStateOptions { + models: DiscoveryModel[]; + value?: string | null; + /** + * Selection callback. New shape: `(model)`. Legacy `(modelId, model)` + * callers can wrap at the call site — see `ModelPickerChangeHandler` + * in ModelPicker.tsx for the migration note. + */ + onChange?: (model: DiscoveryModel) => void; + /** + * Initial grouping strategy. The hook stores this as internal state so + * consumers can let the user switch view (Category ⇆ Provider) without + * lifting the value into the host. Pass a fresh `groupBy` to reset. + */ + groupBy?: GroupStrategy; + /** + * Authoritative recommended/preview model IDs sourced from Model_hub + * configs in gitops-centralized-cluster. Passed through to + * `groupModels` so grouping aligns with chip rendering. + */ + recommendedModelIds?: readonly string[]; + previewModelIds?: readonly string[]; + /** + * Optional per-product filter applied to the catalog *before* grouping + * and search. Runs once per `models` array via `useMemo`; pass a + * stable function reference if `models` is large. + */ + filter?: (model: DiscoveryModel) => boolean; + /** + * Group keys to render as collapsed when the picker first mounts. + * The hook keeps the collapse map as internal state so the parent + * doesn't have to. A non-empty search query temporarily forces every + * group open so matches always show. + */ + initiallyCollapsedGroups?: readonly string[]; + /** + * Lingui i18n instance. Forwarded to `groupModels` so group labels + + * hints render in the host's active locale. Omit only when the host + * has no Lingui provider in scope (tests, isolated primitives). + */ + i18n?: PickerTranslator; +} + +export interface UseModelPickerStateResult { + open: boolean; + setOpen: (next: boolean) => void; + query: string; + setQuery: (next: string) => void; + /** Current grouping strategy. Initially seeded from `opts.groupBy`. */ + groupBy: GroupStrategy; + setGroupBy: (next: GroupStrategy) => void; + /** + * `models` after the host's `filter` ran. Use this for downstream + * counts and side effects — `annotated`/`filtered` already include + * this filter, so most consumers won't need it. + */ + visibleModels: DiscoveryModel[]; + /** All models, annotated with groupKey/groupLabel, sorted into group order. */ + annotated: AnnotatedModel[]; + /** annotated filtered by `query`. */ + filtered: AnnotatedModel[]; + /** Per-group counts (post-filter, pre-query). */ + groupCounts: Record; + /** + * Group keys currently collapsed. While `query` is non-empty every + * group is forced open (returns an empty set) so search results stay + * visible. + */ + collapsedGroups: ReadonlySet; + /** Toggle the collapsed state of a single group. */ + toggleGroup: (groupKey: string) => void; + /** The currently selected model, or null. */ + selected: AnnotatedModel | null; + /** + * Raw `value` that couldn't be resolved against `models`. `null` when + * the selection resolved cleanly, when no `value` was passed, or + * while the catalog is still loading (empty `models`). Used by the + * trigger to render an explicit error-state fallback (raw id + + * red border) instead of silently dropping the value — which would + * look identical to "nothing selected" and risk accidental + * overwrites of a stored config. + */ + unknownValue: string | null; + activeIndex: number; + setActiveIndex: (i: number) => void; + /** Bind to the search input's onKeyDown. */ + onSearchKeyDown: (e: React.KeyboardEvent) => void; + /** Programmatically pick a model. Closes the popup. */ + choose: (m: DiscoveryModel) => void; + /** Stable id prefix unique to this picker instance. */ + id: string; + /** Ref to attach to the trigger button so keyboard focus can return there. */ + triggerRef: React.RefObject; + /** Ref to attach to the search input. */ + searchRef: React.RefObject; +} + +let pickerIdCounter = 0; + +/** + * Shared state controller for ModelPicker (and any custom picker a + * team builds on top of the primitives). Owns: + * - open/close state of the popup + * - search query + filtering + * - grouped + annotated option list + * - keyboard navigation index + * - collapsed-group bookkeeping + * - selection callback + * + * Consumers can use this hook directly to assemble their own picker from + * the exported primitives — see ModelPicker.tsx for the canonical example. + */ +export function useModelPickerState(opts: UseModelPickerStateOptions): UseModelPickerStateResult { + const { + models, + value, + onChange, + groupBy: initialGroupBy = 'subscription', + recommendedModelIds, + previewModelIds, + filter, + initiallyCollapsedGroups, + i18n, + } = opts; + + const id = useMemo(() => { + pickerIdCounter += 1; + return `apollo-model-picker-${pickerIdCounter}`; + }, []); + + const triggerRef = useRef(null); + const searchRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + // Grouping is internal state seeded from the prop. Consumers that need + // controlled grouping can mirror it via the `setGroupBy` setter; the + // common case (host passes initial value, user toggles inside the + // popup) doesn't need any external wiring. + const [groupBy, setGroupBy] = useState(initialGroupBy); + // Sticky per-group collapse state, seeded from `initiallyCollapsedGroups`. + // While the user is searching we force every group open so matches always + // show; the stored set isn't mutated, so the previous collapse state + // returns when the query clears. + const [collapsedSet, setCollapsedSet] = useState>( + () => new Set(initiallyCollapsedGroups ?? []) + ); + + const visibleModels = useMemo(() => (filter ? models.filter(filter) : models), [models, filter]); + + const annotated = useMemo(() => { + const groups = groupModels(visibleModels, groupBy, { + recommendedModelIds, + previewModelIds, + i18n, + }); + return groups.flatMap((g) => + g.models.map((m) => ({ + ...m, + groupKey: g.key, + groupLabel: g.label, + })) + ); + }, [visibleModels, groupBy, recommendedModelIds, previewModelIds, i18n]); + + const filtered = useMemo( + () => filterModels(annotated, query) as AnnotatedModel[], + [annotated, query] + ); + + const groupCounts = useMemo>(() => { + const out: Record = {}; + for (const m of annotated) { + out[m.groupKey] = (out[m.groupKey] ?? 0) + 1; + } + return out; + }, [annotated]); + + // Selection lookup uses `models` (not visibleModels) so a stored + // selection a host filtered out still resolves — otherwise narrowing + // the catalog would silently flip the trigger into "unknown" state + // for legitimate, recently-removed-from-view selections. + const selected = useMemo( + () => annotated.find((m) => m.modelId === value) ?? null, + [annotated, value] + ); + + const unknownValue = useMemo(() => { + if (!value) return null; + if (selected) return null; + if (models.length === 0) return null; + // If `value` exists in the raw catalog but was filtered out by the + // host, we still treat that as "known" — render the placeholder + // rather than the red error state. The host opted into the filter. + if (models.some((m) => m.modelId === value)) return null; + return value; + }, [models, selected, value]); + + // Force every group open while searching so matches always show. + // Once the query clears, the previously-stored collapse state returns. + const collapsedGroups = useMemo>( + () => (query.trim() ? new Set() : collapsedSet), + [query, collapsedSet] + ); + + const toggleGroup = useCallback((groupKey: string) => { + setCollapsedSet((prev) => { + const next = new Set(prev); + if (next.has(groupKey)) next.delete(groupKey); + else next.add(groupKey); + return next; + }); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: runs on open/close only — re-running on `filtered`/`value` would reset the keyboard highlight on every keystroke + useEffect(() => { + if (open) { + const sel = filtered.findIndex((m) => m.modelId === value); + setActiveIndex(sel >= 0 ? sel : 0); + requestAnimationFrame(() => searchRef.current?.focus()); + } else { + setQuery(''); + } + }, [open]); + + useEffect(() => { + if (activeIndex >= filtered.length) setActiveIndex(0); + }, [filtered.length, activeIndex]); + + const choose = useCallback( + (m: DiscoveryModel) => { + onChange?.(m); + setOpen(false); + triggerRef.current?.focus(); + }, + [onChange] + ); + + const onSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const m = filtered[activeIndex]; + if (m) choose(m); + } else if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + triggerRef.current?.focus(); + } + }, + [filtered, activeIndex, choose] + ); + + return { + open, + setOpen, + query, + setQuery, + groupBy, + setGroupBy, + visibleModels, + annotated, + filtered, + groupCounts, + collapsedGroups, + toggleGroup, + selected, + unknownValue, + activeIndex, + setActiveIndex, + onSearchKeyDown, + choose, + id, + triggerRef, + searchRef, + }; +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.test.tsx b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.test.tsx new file mode 100644 index 000000000..49749a20f --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.test.tsx @@ -0,0 +1,181 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PlatformRequestContext } from './usePlatformAccess'; +import { useCanManageByo, useUserFolders } from './usePlatformAccess'; + +const CTX: PlatformRequestContext = { + token: 'test-token', + // Trailing slash on purpose — the hooks must strip it before joining. + baseUrl: 'https://cloud.local/acme/', + tenantName: 'DefaultTenant', + organizationId: 'org-guid', + userId: 'user-guid', +}; + +const FOLDERS_URL = + 'https://cloud.local/acme/DefaultTenant/orchestrator_/api/' + + 'FoldersNavigation/GetFoldersForCurrentUser?take=100'; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + statusText: ok ? 'OK' : 'Server Error', + json: () => Promise.resolve(body), + } as unknown as Response; +} + +const FoldersProbe: React.FC<{ ctx: PlatformRequestContext | null }> = ({ ctx }) => { + const { folders, loading, error, refetch } = useUserFolders(ctx); + return ( +
+ {String(loading)} + {error?.message ?? 'none'} +
+ ); +}; + +const ByoProbe: React.FC<{ ctx: PlatformRequestContext | null }> = ({ ctx }) => { + const { canManage, loading, error } = useCanManageByo(ctx); + return ( +
+ {String(canManage)} + {String(loading)} + {error?.message ?? 'none'} +
+ ); +}; + +describe('useUserFolders', () => { + it('calls FoldersNavigation with the bearer token and maps PageItems', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + Count: 2, + PageItems: [ + { Id: 1, Key: 'guid-a', DisplayName: 'Shared' }, + { Id: 2, Key: 'guid-b', DisplayName: 'Finance' }, + ], + }) + ); + render(); + + expect(await screen.findByText('guid-a|Shared')).toBeInTheDocument(); + expect(screen.getByText('guid-b|Finance')).toBeInTheDocument(); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(fetchMock).toHaveBeenCalledWith( + FOLDERS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ); + }); + + it('surfaces HTTP errors and keeps the folder list empty', async () => { + fetchMock.mockResolvedValue(jsonResponse({}, false, 403)); + render(); + + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent('403')); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + it('does not fetch when disabled (null context)', () => { + render(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + }); + + it('refetch fires the request again', async () => { + const user = userEvent.setup(); + fetchMock.mockResolvedValue(jsonResponse({ Count: 0, PageItems: [] })); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: 'refetch' })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + }); +}); + +describe('useCanManageByo', () => { + it('grants management when the entitlement is held', async () => { + fetchMock.mockResolvedValue(jsonResponse({ isEntitled: { AiTrustLayerByoLlm: true } })); + render(); + + await waitFor(() => expect(screen.getByTestId('can-manage')).toHaveTextContent('true')); + expect(fetchMock).toHaveBeenCalledWith( + 'https://cloud.local/acme/lease_/api/entitlements/org-guid/entitled?userId=user-guid', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ EntitlementNames: ['AiTrustLayerByoLlm'] }), + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('denies management when the entitlement is absent from the response', async () => { + fetchMock.mockResolvedValue(jsonResponse({ isEntitled: {} })); + render(); + + await waitFor(() => expect(screen.getByTestId('can-manage')).toHaveTextContent('false')); + }); + + it('omits the userId query when the context has no user', async () => { + fetchMock.mockResolvedValue(jsonResponse({ isEntitled: { AiTrustLayerByoLlm: true } })); + const noUserCtx: PlatformRequestContext = { + token: CTX.token, + baseUrl: CTX.baseUrl, + tenantName: CTX.tenantName, + organizationId: CTX.organizationId, + }; + render(); + + await waitFor(() => expect(screen.getByTestId('can-manage')).toHaveTextContent('true')); + expect(fetchMock).toHaveBeenCalledWith( + 'https://cloud.local/acme/lease_/api/entitlements/org-guid/entitled', + expect.anything() + ); + }); + + it('fails closed on HTTP errors', async () => { + fetchMock.mockResolvedValue(jsonResponse({}, false, 500)); + render(); + + await waitFor(() => expect(screen.getByTestId('can-manage')).toHaveTextContent('false')); + expect(screen.getByTestId('error')).toHaveTextContent('500'); + }); + + it('fails closed on network errors', async () => { + fetchMock.mockRejectedValue(new Error('network down')); + render(); + + await waitFor(() => expect(screen.getByTestId('can-manage')).toHaveTextContent('false')); + expect(screen.getByTestId('error')).toHaveTextContent('network down'); + }); + + it('resolves to undefined (no affordances) when disabled', () => { + render(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('can-manage')).toHaveTextContent('undefined'); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts new file mode 100644 index 000000000..ec63980cf --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { FolderSwitcherFolder } from './primitives/FolderSwitcher'; + +/** + * Auth + routing context for the picker's built-in platform calls + * (folder list, BYO-management entitlement). Mirrors how the + * Automation Cloud portal reaches the same endpoints. + */ +export interface PlatformRequestContext { + /** Bearer token (no `Bearer ` prefix). */ + token: string; + /** + * Origin + organization path segment, e.g. + * `https://cloud.uipath.com/acme`. Defaults to `''` (same-origin, + * org-implicit) — correct for portal-hosted SPAs where the app is + * already served under the org prefix. + */ + baseUrl?: string; + /** Tenant name (path segment for Orchestrator routes). */ + tenantName: string; + /** Organization GUID — used by the entitlements (license) check. */ + organizationId: string; + /** + * Current user's global id. When provided, the entitlement check is + * evaluated user-scoped (matches the Automation Cloud portal behavior). + */ + userId?: string; +} + +/* ────────────────────────────────────────────────────────────────────── + * Folders + * ─────────────────────────────────────────────────────────────────── */ + +/** Raw folder DTO from Orchestrator's FoldersNavigation API. */ +interface OrchestratorFolderDto { + Id: number; + Key: string; + DisplayName: string; + FullyQualifiedName?: string; +} + +interface FoldersNavigationResponse { + Count: number; + PageItems: OrchestratorFolderDto[]; +} + +export interface UseUserFoldersResult { + /** + * Folders mapped for the picker's switcher: `id` is the folder's + * GUID `Key` — pass it straight through as `folderKey` on the + * Discovery request when the selection changes. + */ + folders: FolderSwitcherFolder[]; + loading: boolean; + error: Error | null; + refetch: () => void; +} + +// Enough for the switcher's flat menu; catalogs with more folders than +// this should scope the picker to a folder upstream instead. +const FOLDERS_PAGE_SIZE = 100; + +/** + * Fetches the Orchestrator folders visible to the current user, the + * same way the Automation Cloud portal does: + * + * GET {baseUrl}/{tenantName}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?take=100 + * Authorization: Bearer + * + * Response shape: `{ Count, PageItems }` where + * each item carries `Id` / `Key` (GUID) / `DisplayName` / + * `FullyQualifiedName`. + * + * Pass `null` to disable (e.g. `enableFolders` is off). Minimal + * fetch-and-state — hosts on SWR/React Query can fetch themselves and + * pass the `folders` prop instead. + */ +export function useUserFolders(ctx: PlatformRequestContext | null): UseUserFoldersResult { + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const fetchFolders = useCallback(async () => { + if (!ctx) return; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(null); + + const base = (ctx.baseUrl ?? '').replace(/\/$/, ''); + const url = + `${base}/${ctx.tenantName}/orchestrator_/api/FoldersNavigation/` + + `GetFoldersForCurrentUser?take=${FOLDERS_PAGE_SIZE}`; + + try { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${ctx.token}`, + Accept: 'application/json', + }, + signal: ctrl.signal, + }); + if (!res.ok) { + throw new Error(`FoldersNavigation API ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as FoldersNavigationResponse; + if (!ctrl.signal.aborted) { + setFolders( + (data.PageItems ?? []).map((f) => ({ + id: f.Key, + label: f.DisplayName, + })) + ); + } + } catch (err: unknown) { + if ((err as { name?: string })?.name === 'AbortError') return; + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (!ctrl.signal.aborted) setLoading(false); + } + }, [ctx]); + + useEffect(() => { + fetchFolders(); + return () => abortRef.current?.abort(); + }, [fetchFolders]); + + return { folders, loading, error, refetch: fetchFolders }; +} + +/* ────────────────────────────────────────────────────────────────────── + * BYO management entitlement + * ─────────────────────────────────────────────────────────────────── */ + +// License entitlement that gates BYO LLM model management in the +// Automation Cloud portal (AI Trust Layer → LLM configurations tab). +const BYO_LLM_ENTITLEMENT = 'AiTrustLayerByoLlm'; + +export interface UseCanManageByoResult { + /** + * `undefined` while loading or when the check is disabled; boolean + * once resolved. The picker treats `undefined` as `false` (no admin + * affordances) so nothing flashes for non-admins. + */ + canManage: boolean | undefined; + loading: boolean; + error: Error | null; +} + +/** + * Checks whether the current user's org (user-scoped when `userId` is + * provided) holds the `AiTrustLayerByoLlm` entitlement — the same + * signal the Automation Cloud portal uses to show BYO LLM management: + * + * POST {baseUrl}/lease_/api/entitlements/{organizationId}/entitled?userId={userId} + * body: { "EntitlementNames": ["AiTrustLayerByoLlm"] } + * → { "isEntitled": { "AiTrustLayerByoLlm": true } } + * + * Note the portal additionally gates the page + * behind org/tenant-admin routes — hosts embedding the picker outside + * an admin surface should pass `canManageByo` explicitly if they need + * stricter gating than the entitlement alone. + * + * Pass `null` to disable (the `canManageByo` prop override is set, or + * no request context is available). + */ +export function useCanManageByo(ctx: PlatformRequestContext | null): UseCanManageByoResult { + const [canManage, setCanManage] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + useEffect(() => { + if (!ctx) { + setCanManage(undefined); + return undefined; + } + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(null); + + const base = (ctx.baseUrl ?? '').replace(/\/$/, ''); + const qs = ctx.userId ? `?userId=${encodeURIComponent(ctx.userId)}` : ''; + const url = `${base}/lease_/api/entitlements/${ctx.organizationId}/entitled${qs}`; + + fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${ctx.token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ EntitlementNames: [BYO_LLM_ENTITLEMENT] }), + signal: ctrl.signal, + }) + .then(async (res) => { + if (!res.ok) { + throw new Error(`Entitlements API ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { + isEntitled?: Record; + }; + if (!ctrl.signal.aborted) { + setCanManage(data.isEntitled?.[BYO_LLM_ENTITLEMENT] === true); + } + }) + .catch((err: unknown) => { + if ((err as { name?: string })?.name === 'AbortError') return; + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err : new Error(String(err))); + // Fail closed: an errored permission check must never grant + // admin affordances. + setCanManage(false); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + + return () => ctrl.abort(); + }, [ctx]); + + return { canManage, loading, error }; +} diff --git a/packages/apollo-react/src/material/components/ap-model-picker/utils.test.ts b/packages/apollo-react/src/material/components/ap-model-picker/utils.test.ts new file mode 100644 index 000000000..b5d3de624 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/utils.test.ts @@ -0,0 +1,336 @@ +import { i18n } from '@lingui/core'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import type { DiscoveryModel } from './types'; +import { + defaultCostTier, + deriveModelTags, + filterModels, + getSubstitutionTarget, + groupModels, +} from './utils'; + +// Helper: minimal Discovery model. Tests override fields as needed. +function model(overrides: Partial): DiscoveryModel { + return { + modelId: 'm-1', + modelName: 'm-1', + vendor: 'OpenAi', + modelSubscriptionType: 'UiPathOwned', + ...overrides, + }; +} + +describe('groupModels (Category view ordering)', () => { + it('places BYO first, then Recommended → Preview → More → Deprecating', () => { + const models = [ + model({ modelId: 'rec', modelSubscriptionType: 'UiPathOwned' }), + model({ + modelId: 'preview', + modelSubscriptionType: 'UiPathOwned', + isPreview: true, + }), + model({ + modelId: 'more', + modelSubscriptionType: 'UiPathOwned', + }), + model({ + modelId: 'depr', + modelSubscriptionType: 'UiPathOwned', + deprecationDetails: { usageEndDate: '2026-09-01' }, + }), + model({ modelId: 'byo', modelSubscriptionType: 'BYOMAdded' }), + ]; + // Override recommended/preview explicitly so the test doesn't depend on + // the DTO heuristic (which would put `rec` and `more` in the same bucket). + const groups = groupModels(models, 'subscription', { + recommendedModelIds: ['rec'], + previewModelIds: ['preview'], + }); + expect(groups.map((g) => g.key)).toEqual([ + 'byo', + 'recommended', + 'preview', + 'more', + 'deprecating', + ]); + }); + + it('drops empty groups without leaving empty headers', () => { + const groups = groupModels([model({ modelId: 'a' })], 'subscription', { + recommendedModelIds: ['a'], + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.key).toBe('recommended'); + }); + + it('Provider view places BYO first (before all vendor groups)', () => { + const models = [ + model({ modelId: 'openai', vendor: 'OpenAi' }), + model({ modelId: 'anthropic', vendor: 'AnthropicClaude' }), + model({ + modelId: 'byo', + vendor: 'OpenAi', + modelSubscriptionType: 'BYOMAdded', + }), + ]; + const groups = groupModels(models, 'vendor'); + expect(groups[0]?.key).toBe('byo'); + }); + + it('Provider view orders each vendor section Recommended → Preview → More → Deprecating', () => { + // Catalog order is deliberately scrambled; the vendor group must + // re-rank by lifecycle while keeping catalog order within a band. + const models = [ + model({ + modelId: 'depr', + vendor: 'OpenAi', + deprecationDetails: { usageEndDate: '2026-09-01' }, + }), + model({ modelId: 'more-a', vendor: 'OpenAi' }), + model({ modelId: 'preview', vendor: 'OpenAi', isPreview: true }), + model({ modelId: 'rec', vendor: 'OpenAi' }), + model({ modelId: 'more-b', vendor: 'OpenAi' }), + // Second vendor to prove ranking is per-section. + model({ modelId: 'claude-rec', vendor: 'AnthropicClaude' }), + model({ + modelId: 'claude-depr', + vendor: 'AnthropicClaude', + deprecationDetails: { usageEndDate: '2026-09-01' }, + }), + ]; + const groups = groupModels(models, 'vendor', { + recommendedModelIds: ['rec', 'claude-rec'], + previewModelIds: ['preview'], + }); + const openai = groups.find((g) => g.key === 'OpenAi'); + expect(openai?.models.map((m) => m.modelId)).toEqual([ + 'rec', + 'preview', + 'more-a', + 'more-b', + 'depr', + ]); + const anthropic = groups.find((g) => g.key === 'AnthropicClaude'); + expect(anthropic?.models.map((m) => m.modelId)).toEqual(['claude-rec', 'claude-depr']); + }); + + it('Model_hub recommendedModelIds overrides DTO heuristic', () => { + const models = [ + // DTO heuristic would treat this as Recommended (UiPathOwned + !preview). + model({ modelId: 'a', modelSubscriptionType: 'UiPathOwned' }), + model({ modelId: 'b', modelSubscriptionType: 'UiPathOwned' }), + ]; + // But Model_hub says only `b` is recommended. + const groups = groupModels(models, 'subscription', { + recommendedModelIds: ['b'], + }); + const rec = groups.find((g) => g.key === 'recommended'); + expect(rec?.models.map((m) => m.modelId)).toEqual(['b']); + // `a` falls through to More models. + const more = groups.find((g) => g.key === 'more'); + expect(more?.models.map((m) => m.modelId)).toEqual(['a']); + }); +}); + +describe('deriveModelTags', () => { + it('emits Recommended chip when Model_hub list includes the model', () => { + const tags = deriveModelTags(model({ modelId: 'a' }), { + recommendedModelIds: ['a'], + }); + expect(tags.find((t) => t.kind === 'recommended')).toBeTruthy(); + }); + + it('emits Preview chip for hosted preview models, never for BYO', () => { + const hostedPreview = deriveModelTags(model({ modelId: 'a', isPreview: true })); + expect(hostedPreview.find((t) => t.kind === 'preview')).toBeTruthy(); + + const byoPreview = deriveModelTags( + model({ + modelId: 'b', + isPreview: true, + modelSubscriptionType: 'BYOMAdded', + }) + ); + expect(byoPreview.find((t) => t.kind === 'preview')).toBeFalsy(); + }); + + it('emits Out-of-region chip only when geography differs from homeRegion', () => { + const usHostedForEuUser = deriveModelTags(model({ routingDetails: { geography: 'US' } }), { + homeRegion: 'EU', + }); + expect(usHostedForEuUser.find((t) => t.kind === 'out-of-region')).toBeTruthy(); + + const euHostedForEuUser = deriveModelTags(model({ routingDetails: { geography: 'EU' } }), { + homeRegion: 'EU', + }); + expect(euHostedForEuUser.find((t) => t.kind === 'out-of-region')).toBeFalsy(); + + // GLOBAL models never trigger the chip. + const globalForEuUser = deriveModelTags(model({ routingDetails: { geography: 'GLOBAL' } }), { + homeRegion: 'EU', + }); + expect(globalForEuUser.find((t) => t.kind === 'out-of-region')).toBeFalsy(); + }); + + it('emits Substituted chip (not Deprecating) when effectiveModel routes elsewhere', () => { + const tags = deriveModelTags( + model({ + modelName: 'gpt-5', + effectiveModel: 'gpt-6', + deprecationDetails: { usageEndDate: '2026-05-01' }, + }) + ); + // Substituted and Deprecating are mutually exclusive — substituted wins + // because retirement already fired. + expect(tags.find((t) => t.kind === 'substituted')).toBeTruthy(); + expect(tags.find((t) => t.kind === 'deprecating')).toBeFalsy(); + }); + + it('appends custom tags after built-in chips', () => { + const tags = deriveModelTags(model({ modelId: 'a' }), { + recommendedModelIds: ['a'], + customTagsFor: () => [ + { kind: 'multimodal', label: 'Multimodal' }, + { kind: 'onprem', label: 'On-prem' }, + ], + }); + const customIdx = tags.findIndex((t) => t.kind === 'multimodal'); + const recIdx = tags.findIndex((t) => t.kind === 'recommended'); + expect(customIdx).toBeGreaterThan(recIdx); + }); + + it('never stamps cost chips itself — cost is a customTagsFor decision', () => { + // A model with full cost data still produces no cost-* chips from + // the core derivation. + const tags = deriveModelTags( + model({ + modelId: 'a', + modelDetails: { costDetails: { inputTokenCost: 6 } }, + }) + ); + expect(tags.find((t) => t.kind.startsWith('cost-'))).toBeFalsy(); + }); + + it('supports the agents cost-badge pattern via customTagsFor + defaultCostTier', () => { + const costBadges = (m: DiscoveryModel) => { + const tier = defaultCostTier(m); + return tier ? [{ kind: `cost-${tier}`, label: tier }] : []; + }; + const tags = deriveModelTags( + model({ + modelId: 'a', + modelDetails: { costDetails: { inputTokenCost: 6 } }, + }), + { customTagsFor: costBadges } + ); + expect(tags.find((t) => t.kind === 'cost-premium')).toBeTruthy(); + }); + + it('DTO isRecommended (Model_hub merged by Discovery) wins over the heuristic', () => { + // isPreview=true would normally exclude the model from Recommended, + // but the backend-merged isRecommended field is authoritative. + const promoted = deriveModelTags(model({ modelId: 'a', isPreview: true, isRecommended: true })); + expect(promoted.find((t) => t.kind === 'recommended')).toBeTruthy(); + + // Conversely an explicit false suppresses the heuristic's yes. + const demoted = deriveModelTags( + model({ + modelId: 'b', + modelSubscriptionType: 'UiPathOwned', + isRecommended: false, + }) + ); + expect(demoted.find((t) => t.kind === 'recommended')).toBeFalsy(); + }); +}); + +describe('defaultCostTier', () => { + it('returns null when no cost data', () => { + expect(defaultCostTier(model({}))).toBeNull(); + }); + + it('bins at < $1 / < $5 / >= $5 thresholds', () => { + expect(defaultCostTier(model({ modelDetails: { costDetails: { inputTokenCost: 0.4 } } }))).toBe( + 'basic' + ); + expect(defaultCostTier(model({ modelDetails: { costDetails: { inputTokenCost: 3 } } }))).toBe( + 'standard' + ); + expect(defaultCostTier(model({ modelDetails: { costDetails: { inputTokenCost: 6 } } }))).toBe( + 'premium' + ); + }); +}); + +describe('getSubstitutionTarget', () => { + it('prefers replacedBy over effectiveModel over routingDetails.model', () => { + expect( + getSubstitutionTarget( + model({ + modelName: 'a', + effectiveModel: 'b', + deprecationDetails: { replacedBy: 'c' }, + }) + ) + ).toBe('c'); + + expect(getSubstitutionTarget(model({ modelName: 'a', effectiveModel: 'b' }))).toBe('b'); + + expect(getSubstitutionTarget(model({ modelName: 'a', routingDetails: { model: 'b' } }))).toBe( + 'b' + ); + }); + + it('returns null when there is no substitution', () => { + expect( + getSubstitutionTarget(model({ modelName: 'gpt-5', effectiveModel: 'gpt-5' })) + ).toBeNull(); + expect(getSubstitutionTarget(model({ modelName: 'gpt-5' }))).toBeNull(); + }); +}); + +describe('filterModels', () => { + it('matches case-insensitively across name, id, vendor', () => { + const models = [ + model({ + modelId: 'anthropic.claude', + modelName: 'Claude', + vendor: 'AnthropicClaude', + }), + model({ modelId: 'openai.gpt-4', modelName: 'GPT-4', vendor: 'OpenAi' }), + ]; + expect(filterModels(models, 'claude')).toHaveLength(1); + expect(filterModels(models, 'gpt-4')).toHaveLength(1); + expect(filterModels(models, 'CLAUDE')).toHaveLength(1); + }); + + it('returns everything for empty query', () => { + const models = [model({ modelId: 'a' }), model({ modelId: 'b' })]; + expect(filterModels(models, '')).toEqual(models); + expect(filterModels(models, ' ')).toEqual(models); + }); +}); + +describe('i18n resolution', () => { + beforeAll(() => { + // No catalogs are loaded; lingui falls back to each descriptor's + // English `message`. This confirms the descriptor system end-to-end. + i18n.activate('en'); + }); + + it('resolves tag labels through the i18n instance', () => { + const tags = deriveModelTags(model({ modelId: 'a' }), { + recommendedModelIds: ['a'], + i18n, + }); + expect(tags.find((t) => t.kind === 'recommended')?.label).toBe('Recommended'); + }); + + it('falls back to source English when no i18n instance is supplied', () => { + const tags = deriveModelTags(model({ modelId: 'a' }), { + recommendedModelIds: ['a'], + }); + expect(tags.find((t) => t.kind === 'recommended')?.label).toBe('Recommended'); + }); +}); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/utils.ts b/packages/apollo-react/src/material/components/ap-model-picker/utils.ts new file mode 100644 index 000000000..b1f95dbf9 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-model-picker/utils.ts @@ -0,0 +1,484 @@ +import type { PickerTranslator } from './i18n'; + +import { GROUP_LABELS, TAG_LABELS, tr } from './i18n'; +import type { CostTier, DiscoveryModel, ModelGroup, ModelTag } from './types'; + +const RECOMMENDED_SUBSCRIPTION = 'UiPathOwned'; + +/** + * Context passed to `deriveModelTags`. Carries the i18n instance, + * region info, test-only Recommended/Preview overrides, and the + * product's custom badge hook. + */ +export interface DeriveModelTagsContext { + /** + * Lingui i18n instance. When provided, the built-in tag labels + + * tooltips render in the active locale. When omitted, labels fall + * back to the message descriptors' English source strings — useful + * for tests and standalone primitive composition. + */ + i18n?: PickerTranslator; + /** User's home region — used to flag out-of-region models. */ + homeRegion?: string; + /** + * Test/storybook override for the `recommended` signal. In + * production the signal arrives ON the DTO (`model.isRecommended`), + * merged into the Discovery response from Model_hub configs in + * `gitops-centralized-cluster` — products should not set this. + * When provided (even as an empty array), only listed ids get the + * chip. + */ + recommendedModelIds?: readonly string[]; + /** + * Test/storybook override for the `preview` signal. Production + * sources it from the DTO's `isPreview`. + */ + previewModelIds?: readonly string[]; + /** + * Product-controlled extra chips. Returned tags are appended *after* + * the built-in derived chips (Recommended → Preview → Substituted → + * Deprecating → Custom → Out-of-region) so the picker's canonical + * signals always sort first. Use this for product-specific badges — + * cost tiers (see `defaultCostTier` for the agents example), + * "Multimodal", "Routes via On-Prem", a tenant SKU label — anything + * the design system shouldn't bake into the catalog. + * + * To color a kind the picker doesn't know about, register a variant + * via ``. + * Unknown kinds fall back to the neutral gray `mini` chip — visible + * but not loud. + */ + customTagsFor?: (model: DiscoveryModel) => readonly ModelTag[]; +} + +export function deriveModelTags( + model: DiscoveryModel, + context: DeriveModelTagsContext = {} +): ModelTag[] { + const tags: ModelTag[] = []; + // Resolve a message descriptor against the (optional) i18n instance. + // When no instance is supplied (tests, primitive composition) we + // fall back to the descriptor's source English message so the chip + // still renders something legible. + const localize = (desc: { id: string; message?: string }): string => { + if (context.i18n) return tr(context.i18n, desc); + return desc.message ?? desc.id; + }; + + // `preview` and `out-of-region` apply only to UiPath-hosted models: + // - Preview: UiPath controls the GA lifecycle for hosted models. For BYO, + // the customer configured the connection themselves and already knows + // what they hooked up — restating it as a chip is noise. + // - Out-of-region: gateway controls routing for hosted models. BYO models + // route to the customer's own endpoint, which they configured; the + // gateway doesn't know or control its region. + const isByo = + model.modelSubscriptionType === 'BYOMAdded' || + model.modelSubscriptionType === 'BYOMReplacedAlternative' || + model.modelSubscriptionType === 'BYOMReplacedLikeForLike'; + + // `recommended` is governance authored in Model_hub configs + // (gitops-centralized-cluster) and merged into the Discovery + // response server-side — the DTO's `isRecommended` is the production + // source of truth. Resolution: test override list → DTO field → + // legacy heuristic (for backends that haven't rolled the field out). + const isRecommended = + context.recommendedModelIds !== undefined + ? context.recommendedModelIds.includes(model.modelId) + : (model.isRecommended ?? + (model.modelSubscriptionType === RECOMMENDED_SUBSCRIPTION && + !model.isPreview && + !model.deprecationDetails?.usageEndDate)); + if (isRecommended) { + tags.push({ + kind: 'recommended', + label: localize(TAG_LABELS.recommended), + tooltip: localize(TAG_LABELS.recommendedTooltip), + }); + } + + // Same story for `preview` — Model_hub-driven when the host supplies a + // list, DTO `isPreview` otherwise. + const isPreview = + context.previewModelIds !== undefined + ? context.previewModelIds.includes(model.modelId) + : !!model.isPreview; + if (!isByo && isPreview) { + tags.push({ kind: 'preview', label: localize(TAG_LABELS.preview) }); + } + + // A "substitution" is when the gateway is actively routing traffic to a + // different upstream model than the one the user picked. This happens + // when a model has been retired and a routing rule maps it to a + // replacement, but the user's stored selection still references the + // original. Detected when `effectiveModel` exists and differs from the + // model's primary identifier. + // + // `substituted` and `deprecating` are mutually exclusive: + // - `deprecating` = retirement is scheduled; you should migrate. + // - `substituted` = retirement already fired; your traffic is being + // transparently routed somewhere else. + const substitutionTarget = getSubstitutionTarget(model); + if (substitutionTarget) { + // Dynamic strings need the i18n.t API so values interpolate into + // the translation. The descriptor's English source is used as the + // fallback when no i18n instance is supplied. + tags.push({ + kind: 'substituted', + label: context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.substituted.label', + message: 'Routes to {target}', + values: { target: substitutionTarget }, + }) + : `Routes to ${substitutionTarget}`, + tooltip: context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.substituted.tooltip', + message: + 'This model is retired. Your traffic is currently being routed to {target}. Update your configuration to make this explicit.', + values: { target: substitutionTarget }, + }) + : `This model is retired. Your traffic is currently being routed to ${substitutionTarget}. Update your configuration to make this explicit.`, + }); + } else if (model.deprecationDetails?.usageEndDate) { + const date = formatDate(model.deprecationDetails.usageEndDate); + tags.push({ + kind: 'deprecating', + label: context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.deprecating.label', + message: 'Deprecating {date}', + values: { date }, + }) + : `Deprecating ${date}`, + tooltip: model.deprecationDetails.replacedBy + ? context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.deprecating.tooltip', + message: 'Will be replaced by {replacement}', + values: { replacement: model.deprecationDetails.replacedBy }, + }) + : `Will be replaced by ${model.deprecationDetails.replacedBy}` + : undefined, + }); + } + + if (isByo) { + tags.push({ kind: 'custom', label: localize(TAG_LABELS.custom) }); + } + + if (!isByo) { + const geo = model.routingDetails?.geography; + if (geo && context.homeRegion && geo !== 'GLOBAL' && geo !== context.homeRegion) { + tags.push({ + kind: 'out-of-region', + label: context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.outOfRegion.label', + message: 'Out of region ({geography})', + values: { geography: geo }, + }) + : `Out of region (${geo})`, + tooltip: context.i18n + ? context.i18n._({ + id: 'modelPicker.tag.outOfRegion.tooltip', + message: 'Routes traffic outside {homeRegion}', + values: { homeRegion: context.homeRegion }, + }) + : `Routes traffic outside ${context.homeRegion}`, + }); + } + } + + // Product-specific chips come last — the picker's canonical signals + // (Recommended / Preview / lifecycle) should always read first so + // users see the design-system semantics before any tenant noise. + if (context.customTagsFor) { + const extra = context.customTagsFor(model); + if (extra?.length) tags.push(...extra); + } + + return tags; +} + +/** + * EXAMPLE cost-tier classifier — the picker does NOT stamp cost chips + * itself. Products that want them (agents does) wire this through + * `customTagsFor`: + * + * customTagsFor={(m) => { + * const tier = defaultCostTier(m); + * return tier ? [{ kind: `cost-${tier}`, label: tierLabel(tier) }] : []; + * }} + * + * Bins the Discovery DTO's `modelDetails.costDetails.inputTokenCost` + * (USD per million input tokens). Returns `null` when the model has no + * cost data, so callers can suppress the chip rather than show a + * misleading tier. Thresholds snapshot 2026-Q2 gateway pricing: + * - basic : < $1.00/M input (e.g. gpt-4o-mini, gemini-flash) + * - standard: $1.00 – $5.00/M (e.g. gpt-4o, claude-sonnet) + * - premium : > $5.00/M (e.g. claude-opus, gpt-5 large) + */ +const DEFAULT_BASIC_THRESHOLD = 1.0; +const DEFAULT_PREMIUM_THRESHOLD = 5.0; +export function defaultCostTier(model: DiscoveryModel): CostTier | null { + const inputCost = model.modelDetails?.costDetails?.inputTokenCost; + if (inputCost == null) return null; + if (inputCost < DEFAULT_BASIC_THRESHOLD) return 'basic'; + if (inputCost < DEFAULT_PREMIUM_THRESHOLD) return 'standard'; + return 'premium'; +} + +/** + * Returns the model identifier the gateway is *actually* routing to, + * if it differs from the user's stored selection. Used to surface + * silent substitutions (e.g., a retired model whose traffic is being + * routed to a replacement via a gateway rule) so the user understands + * what their workflow is really running against. + * + * Falls back through three sources, in order of trust: + * 1. `deprecationDetails.replacedBy` — the explicit replacement name. + * Prefer this because it's the friendly identifier the migration + * rule was authored with. + * 2. `effectiveModel` from the Discovery DTO — the actual upstream + * model name being invoked. + * 3. `routingDetails.model` — the routed model when no other signal + * is set. + * + * Returns null when there's no substitution. + */ +export function getSubstitutionTarget(model: DiscoveryModel): string | null { + // Discovery returns effectiveModel populated only when routing diverges + // from the model's nominal identity. If it matches the modelName, the + // user's selection IS what's running — no substitution. + const effective = model.effectiveModel; + if (effective && effective !== model.modelName && effective !== model.modelId) { + return model.deprecationDetails?.replacedBy ?? effective; + } + const routed = model.routingDetails?.model; + if (routed && routed !== model.modelName && routed !== model.modelId) { + return model.deprecationDetails?.replacedBy ?? routed; + } + return null; +} + +/** + * Maps the raw Discovery `vendor` enum (UpperCamel, occasionally jarring + * like `AnthropicClaude`) to a label that reads naturally in the + * by-provider grouping header. Unknown vendors fall through unchanged. + */ +const VENDOR_LABELS: Record = { + AnthropicClaude: 'Anthropic', + OpenAi: 'OpenAI', + AzureOpenAi: 'Azure OpenAI', + VertexAi: 'Google Vertex', + AwsBedrock: 'AWS Bedrock', +}; +function vendorLabel(vendor: string): string { + return VENDOR_LABELS[vendor] ?? vendor; +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }); + } catch { + return iso; + } +} + +export type GroupStrategy = 'subscription' | 'vendor' | 'flat'; + +/** + * Optional context for `groupModels` — lets the host override the + * Recommended/Preview heuristic with Model_hub-sourced lists so that + * grouping and chip rendering agree, and inject the active Lingui + * `i18n` instance so group labels render in the host's locale. + */ +export interface GroupModelsContext { + recommendedModelIds?: readonly string[]; + previewModelIds?: readonly string[]; + /** + * Lingui i18n instance. When provided, group labels + hints render + * in the active locale; otherwise they fall back to English source + * strings. + */ + i18n?: PickerTranslator; +} + +const isByoModel = (m: DiscoveryModel) => + m.modelSubscriptionType === 'BYOMAdded' || + m.modelSubscriptionType === 'BYOMReplacedAlternative' || + m.modelSubscriptionType === 'BYOMReplacedLikeForLike'; + +function buildSubscriptionMatchers(ctx: GroupModelsContext): Array<{ + key: string; + label: string; + hint?: string; + match: (m: DiscoveryModel) => boolean; +}> { + // Same resolution order as `deriveModelTags` so grouping and chip + // rendering never disagree: test override → DTO `isRecommended` + // (Model_hub merged into Discovery server-side) → legacy heuristic. + const isRecommended = (m: DiscoveryModel): boolean => + ctx.recommendedModelIds !== undefined + ? ctx.recommendedModelIds.includes(m.modelId) + : (m.isRecommended ?? + (m.modelSubscriptionType === RECOMMENDED_SUBSCRIPTION && + !m.isPreview && + !m.deprecationDetails?.usageEndDate)); + const isPreview = (m: DiscoveryModel): boolean => + ctx.previewModelIds !== undefined ? ctx.previewModelIds.includes(m.modelId) : !!m.isPreview; + const localize = (desc: { id: string; message?: string }): string => { + if (ctx.i18n) return tr(ctx.i18n, desc); + return desc.message ?? desc.id; + }; + // Category view ordering: BYO first, then UiPath-hosted lifecycle. + // Customers who bring their own connections expect them up front, not + // buried below the hosted catalog. The matchers below are also the + // render order — first match wins per model. + return [ + { + key: 'byo', + label: localize(GROUP_LABELS.byo), + hint: localize(GROUP_LABELS.byoHint), + match: (m) => isByoModel(m), + }, + { + key: 'recommended', + label: localize(GROUP_LABELS.recommended), + hint: localize(GROUP_LABELS.recommendedHint), + match: (m) => isRecommended(m), + }, + { + key: 'preview', + label: localize(GROUP_LABELS.preview), + hint: localize(GROUP_LABELS.previewHint), + // Preview only applies to UiPath-hosted models. BYO already + // matched above, so this branch will not see BYO models. + match: (m) => isPreview(m) && !isByoModel(m), + }, + { + key: 'more', + label: localize(GROUP_LABELS.more), + hint: localize(GROUP_LABELS.moreHint), + match: (m) => !isByoModel(m) && !m.deprecationDetails?.usageEndDate, + }, + { + key: 'deprecating', + label: localize(GROUP_LABELS.deprecating), + hint: localize(GROUP_LABELS.deprecatingHint), + match: (m) => !!m.deprecationDetails?.usageEndDate, + }, + { + key: 'shared', + label: localize(GROUP_LABELS.other), + match: () => true, + }, + ]; +} + +export function groupModels( + models: DiscoveryModel[], + strategy: GroupStrategy = 'subscription', + context: GroupModelsContext = {} +): ModelGroup[] { + const localize = (desc: { id: string; message?: string }): string => { + if (context.i18n) return tr(context.i18n, desc); + return desc.message ?? desc.id; + }; + + if (strategy === 'flat') { + return [{ key: 'all', label: localize(GROUP_LABELS.allModels), models }]; + } + + if (strategy === 'vendor') { + // BYO models share vendor enums with hosted models (a BYO `gpt-4o` + // is still `OpenAi`) but conceptually belong to a different + // catalog. Pull BYO out as a single group placed FIRST, then + // per-vendor groups follow — matches Category view's BYO-first + // ordering. + const byVendor = new Map(); + const byo: DiscoveryModel[] = []; + for (const m of models) { + if (isByoModel(m)) { + byo.push(m); + continue; + } + const k = m.vendor || 'Other'; + if (!byVendor.has(k)) byVendor.set(k, []); + byVendor.get(k)!.push(m); + } + const groups: ModelGroup[] = []; + if (byo.length) { + groups.push({ + key: 'byo', + label: localize(GROUP_LABELS.byo), + models: byo, + }); + } + // Within each provider section, order by lifecycle — Recommended, + // then Preview, then the rest, with Deprecating last. Reuses the + // Category matchers (whose array order IS the lifecycle rank) so + // the two views never disagree on what counts as Recommended or + // Preview. Ties keep catalog order (`sort` is stable). + const matchers = buildSubscriptionMatchers(context); + const ranks = new Map(); + const lifecycleRank = (m: DiscoveryModel): number => { + let r = ranks.get(m); + if (r === undefined) { + const i = matchers.findIndex((g) => g.match(m)); + r = i === -1 ? matchers.length : i; + ranks.set(m, r); + } + return r; + }; + for (const [key, list] of byVendor.entries()) { + groups.push({ + key, + label: vendorLabel(key), + models: list.sort((a, b) => lifecycleRank(a) - lifecycleRank(b)), + }); + } + return groups; + } + + const matchers = buildSubscriptionMatchers(context); + const buckets: Record = {}; + for (const m of models) { + const bucket = matchers.find((g) => g.match(m)); + if (!bucket) continue; + const list = buckets[bucket.key] ?? (buckets[bucket.key] = []); + list.push(m); + } + + const out: ModelGroup[] = []; + for (const g of matchers) { + const list = buckets[g.key]; + if (!list?.length) continue; + out.push({ key: g.key, label: g.label, hint: g.hint, models: list }); + } + return out; +} + +export function filterModels(models: DiscoveryModel[], query: string): DiscoveryModel[] { + const q = query.trim().toLowerCase(); + if (!q) return models; + return models.filter((m) => { + const haystack = [ + m.modelName, + m.modelId, + m.vendor, + m.modelFamily, + m.effectiveModel, + m.byoConnectionLabel, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); +} diff --git a/packages/apollo-react/src/material/components/index.ts b/packages/apollo-react/src/material/components/index.ts index be5e78eaf..ebb40b5ae 100644 --- a/packages/apollo-react/src/material/components/index.ts +++ b/packages/apollo-react/src/material/components/index.ts @@ -9,6 +9,7 @@ export * from './ap-icon'; export * from './ap-icon-button'; export * from './ap-link'; export * from './ap-menu'; +export * from './ap-model-picker'; export * from './ap-modal'; export * from './ap-popover'; export * from './ap-progress-spinner'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4facbe030..281f8c8d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,10 +131,10 @@ importers: version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nextra: specifier: ^4.6.1 - version: 4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) nextra-theme-docs: specifier: ^4.6.1 - version: 4.6.1(@types/react@19.2.8)(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + version: 4.6.1(@types/react@19.2.8)(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) postcss: specifier: ^8.5.14 version: 8.5.15 @@ -333,10 +333,10 @@ importers: version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nextra: specifier: ^4.6.1 - version: 4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + version: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) nextra-theme-docs: specifier: ^4.6.1 - version: 4.6.1(@types/react@19.2.8)(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + version: 4.6.1(@types/react@19.2.8)(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) pkce-challenge: specifier: ^6.0.0 version: 6.0.0 @@ -642,6 +642,9 @@ importers: '@mui/x-tree-view': specifier: ^8.21.0 version: 8.28.5(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react@19.2.3))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react@19.2.3))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-virtual': + specifier: ^3.14.3 + version: 3.14.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/core': specifier: ^3.19.0 version: 3.19.0(@tiptap/pm@3.19.0) @@ -5974,6 +5977,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.3': + resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.167.4': resolution: {integrity: sha512-Gk5V9Zr5JFJ4SbLyCheQLJ3MnXddccENPA+DJRz+9g3QxtN8DJB8w8KCUCgDeYlWp4LvmO4nX3fy3tupqVP2Pw==} engines: {node: '>=20.19'} @@ -5989,6 +5998,9 @@ packages: '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8508,7 +8520,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true github-slugger@2.0.0: @@ -18003,6 +18015,12 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@tanstack/react-virtual@3.14.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/virtual-core': 3.17.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@tanstack/router-core@1.167.4': dependencies: '@tanstack/history': 1.161.6 @@ -18019,6 +18037,8 @@ snapshots: '@tanstack/virtual-core@3.13.12': {} + '@tanstack/virtual-core@3.17.1': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -22964,13 +22984,13 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@4.6.1(@types/react@19.2.8)(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): + nextra-theme-docs@4.6.1(@types/react@19.2.8)(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): dependencies: '@headlessui/react': 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) clsx: 2.1.1 next: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - nextra: 4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + nextra: 4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) react: 19.2.3 react-compiler-runtime: 19.1.0-rc.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3) @@ -22982,7 +23002,7 @@ snapshots: - immer - use-sync-external-store - nextra@4.6.1(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + nextra@4.6.1(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@headlessui/react': 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) From 714d3f6a28681d09bf781008eaf3b445817fdb9c Mon Sep 17 00:00:00 2001 From: denispetre Date: Fri, 3 Jul 2026 11:14:00 +0300 Subject: [PATCH 2/4] fix(apollo-react): make ModelPicker render without Node globals or a MUI theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard the dev-only duplicate-folder warning with `typeof process` so Vite-served hosts (Storybook) don't hit `process is not defined` - bake the mini-chip variant styles into ModelTagChip via sx (CSS variables + Apollo light fallbacks) instead of relying on the Apollo MuiChip theme overrides — chips now honor the picker's no-ThemeProvider contract and render correctly in dark themes Co-Authored-By: Claude Fable 5 --- .../ap-model-picker/ModelPicker.tsx | 12 +++- .../ap-model-picker/ModelTagChip.tsx | 56 ++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx index 5790cb311..664de4160 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.tsx @@ -414,15 +414,21 @@ export const ModelPicker = React.forwardRef const effectiveFolders = folders ?? (enableFolders ? fetchedFolders : undefined); // Dev-time guard: duplicate folder ids silently break the switcher's - // selection highlight. Warn once per list change. + // selection highlight. Warn once per list change. `typeof process` + // keeps this safe in browsers that don't shim Node globals (Vite + // serves Storybook without one); the warning is a Node/dev-bundler + // nicety, not a runtime feature. React.useEffect(() => { - if (process.env['NODE_ENV'] === 'production' || !effectiveFolders) { + if ( + typeof process === 'undefined' || + process.env['NODE_ENV'] === 'production' || + !effectiveFolders + ) { return; } const seen = new Set(); for (const f of effectiveFolders) { if (seen.has(f.id)) { - // eslint-disable-next-line no-console console.warn( `[ModelPicker] Duplicate folder id "${f.id}" — folder selection will misbehave.` ); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx index b9874981d..1c00e6926 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelTagChip.tsx @@ -10,11 +10,12 @@ import type React from 'react'; import type { ModelTag } from './types'; /** - * Each tag kind maps to one of Apollo's semantic Chip variants - * (`.success | .warning | .info | .error`) — defined in - * `@uipath/apollo-mui5/src/overrides/MuiChip.ts`. We use the `-mini` suffix - * to get the compact 16px-tall semibold pill that matches Apollo's tag - * pattern. Kinds without a clean semantic mapping fall back to the + * Each tag kind maps to one of Apollo's semantic mini-chip variants + * (`success | warning | info | error | neutral`) — the compact + * 16px-tall semibold pill from Apollo's tag pattern. The styles are + * baked into the chip below (see `MINI_COLORS`), so no MUI theme is + * required; the variant name doubles as a className for hosts that + * target it. Kinds without a clean semantic mapping fall back to the * default mini chip (gray, neutral). * * Mapping rationale: @@ -45,6 +46,36 @@ const VARIANT_MAP: Record = { 'cost-premium': 'mini', }; +/** + * Self-contained styling for the mini-chip variants, mirroring Apollo's + * `MuiChip` theme overrides. Baked in via `sx` so the chip renders + * correctly without a ThemeProvider (bare web-component hosts, Vite + * Storybook) — same CSS-variable contract as the rest of the picker, + * with Apollo light-theme values as fallbacks. + */ +const MINI_COLORS: Record = { + mini: { + color: 'var(--color-foreground, #273139)', + backgroundColor: 'var(--color-background-secondary, #f4f5f7)', + }, + 'info-mini': { + color: 'var(--color-info-foreground, #1665b3)', + backgroundColor: 'var(--color-info-background, #e9f1fa)', + }, + 'success-mini': { + color: 'var(--color-success-text, #038108)', + backgroundColor: 'var(--color-success-background, #eeffe5)', + }, + 'warning-mini': { + color: 'var(--color-warning-text, #9e6100)', + backgroundColor: 'var(--color-warning-background, #fff3db)', + }, + 'error-mini': { + color: 'var(--color-error-text, #a6040a)', + backgroundColor: 'var(--color-error-background, #fff0f1)', + }, +}; + // Icon glyphs for built-in tag kinds. Picked from `@mui/icons-material` // because that's already used elsewhere in apollo-react (no new dep); // the design handoff's stroke-style glyphs (✓, ⚠, 🌐, ⤴) are best @@ -95,6 +126,10 @@ export const ModelTagChip: React.FC = ({ tag, variants, icons // surface). const Icon = icons && tag.kind in icons ? icons[tag.kind] : ICON_MAP[tag.kind]; + // Unknown variant strings (product-invented kinds without a matching + // entry) get the neutral gray mini treatment, same as the theme did. + const colors = MINI_COLORS[variantClass] ?? MINI_COLORS['mini']; + const chip = ( = ({ tag, variants, icons ) : undefined } sx={{ - // Apollo's `.-mini` overrides set height/font/padding - // to 0; we just need a small horizontal pad on the label. + // Mini-chip metrics from Apollo's MuiChip overrides (16px pill, + // FontXs 10/16 semibold, horizontal padding carried by the label). + height: '16px', + paddingLeft: 0, + paddingRight: 0, + fontSize: '10px', + lineHeight: '16px', + fontWeight: 600, + ...colors, '& .MuiChip-label': { px: 0.75 }, // The leading icon needs a touch of breathing room from the // label without inheriting the variant's text color directly From 3c18492506482a6edc3c12b4a839009e6ff03d4a Mon Sep 17 00:00:00 2001 From: denispetre Date: Fri, 3 Jul 2026 11:15:13 +0300 Subject: [PATCH 3/4] fix(apollo-react): theme-independent text color for folder menu items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MenuItem inherited MUI's default (light) text color, which reads dark-on-dark in dark hosts without a ThemeProvider — same class of bug as the chip variants. Paint it from --color-foreground like the rest of the picker. Co-Authored-By: Claude Fable 5 --- .../ap-model-picker/primitives/FolderSwitcher.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx index 45155c569..4c44f9429 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx @@ -149,6 +149,7 @@ export const FolderSwitcher: React.FC = ({ gap: 1, py: 0.875, borderRadius: '7px', + color: `var(--color-foreground, ${Colors.ColorGray850})`, }} > = ({ onChange(f.id); setAnchorEl(null); }} - sx={{ fontSize: 13, gap: 1, py: 0.875, borderRadius: '7px' }} + sx={{ + fontSize: 13, + gap: 1, + py: 0.875, + borderRadius: '7px', + color: `var(--color-foreground, ${Colors.ColorGray850})`, + }} > Date: Fri, 3 Jul 2026 11:31:02 +0300 Subject: [PATCH 4/4] fix(apollo-react): address Copilot review findings on ModelPicker - fill en.json with English defaults so hosts that load the catalog don't render blank strings (matches ap-chat) - resolve host-filtered selections from the raw catalog so the trigger keeps showing the stored model (documented contract; new test) - deep-camelize Discovery DTO keys; nested PascalCase (ModelDetails, CostDetails, DeprecationDetails, ...) previously stayed raw - reset state and abort in-flight requests when useDiscoveryModels / useUserFolders / useCanManageByo are disabled mid-flight - guard formatDate against Invalid Date leaking into chip labels - useId() for picker DOM ids instead of a module counter - aria-haspopup="menu" on the folder switcher; role="button" on the collapsible group header Co-Authored-By: Claude Fable 5 --- .../ap-model-picker/ModelPicker.test.tsx | 14 ++++ .../ap-model-picker/locales/en.json | 76 +++++++++---------- .../primitives/FolderSwitcher.tsx | 2 +- .../primitives/GroupHeader.tsx | 1 + .../ap-model-picker/useDiscoveryModels.ts | 36 ++++++--- .../ap-model-picker/useModelPickerState.ts | 38 +++++----- .../ap-model-picker/usePlatformAccess.ts | 16 +++- .../components/ap-model-picker/utils.ts | 17 +++-- 8 files changed, 122 insertions(+), 78 deletions(-) diff --git a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx index 5b21d3a9a..a0cacd9e8 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/ModelPicker.test.tsx @@ -78,6 +78,20 @@ describe('', () => { expect(onChange.mock.calls[0][0].modelId).toBe('gpt-4o'); }); + it('keeps rendering a selection the host filter excluded', () => { + renderPicker( + {}} + filter={(m) => m.vendor !== 'OpenAi'} + /> + ); + // The stored selection resolves from the raw catalog, so the trigger + // shows the model instead of a blank placeholder (README §1). + expect(screen.getByText('gpt-4o')).toBeInTheDocument(); + }); + it('falls back to "unknown model" treatment when value does not match catalog', () => { renderPicker( {}} />); expect(screen.getByText('some-retired-model')).toBeInTheDocument(); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json b/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json index 1ecb2baa3..58a3c273f 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json +++ b/packages/apollo-react/src/material/components/ap-model-picker/locales/en.json @@ -1,40 +1,40 @@ { - "modelPicker.count.one": "", - "modelPicker.count.many": "", - "modelPicker.folderSwitcher.allFolders": "", - "modelPicker.group.allModels.label": "", - "modelPicker.group.more.hint": "", - "modelPicker.tag.recommended.tooltip": "", - "modelPicker.group.recommended.hint": "", - "modelPicker.useCustomModel.subtitle": "", - "modelPicker.groupBy.category": "", - "modelPicker.tag.custom.label": "", - "modelPicker.group.byo.label": "", - "modelPicker.tag.deprecating.label": "", - "modelPicker.group.deprecating.label": "", - "modelPicker.row.editConnection": "", - "modelPicker.groupBy.ariaLabel": "", - "modelPicker.loading.label": "", - "modelPicker.group.deprecating.hint": "", - "modelPicker.label.default": "", - "modelPicker.listbox.label": "", - "modelPicker.group.byo.hint": "", - "modelPicker.group.more.label": "", - "modelPicker.group.preview.hint": "", - "modelPicker.group.other.label": "", - "modelPicker.tag.outOfRegion.label": "", - "modelPicker.useCustomModel.disabledHint": "", - "modelPicker.tag.preview.label": "", - "modelPicker.group.preview.label": "", - "modelPicker.groupBy.provider": "", - "modelPicker.tag.recommended.label": "", - "modelPicker.group.recommended.label": "", - "modelPicker.row.removeConnection": "", - "modelPicker.tag.substituted.label": "", - "modelPicker.tag.outOfRegion.tooltip": "", - "modelPicker.search.placeholder": "", - "modelPicker.placeholder.selectAModel": "", - "modelPicker.tag.substituted.tooltip": "", - "modelPicker.useCustomModel.title": "", - "modelPicker.tag.deprecating.tooltip": "" + "modelPicker.count.one": "{n} model", + "modelPicker.count.many": "{n} models", + "modelPicker.folderSwitcher.allFolders": "All folders", + "modelPicker.group.allModels.label": "All models", + "modelPicker.group.more.hint": "Available, not currently promoted by this product", + "modelPicker.tag.recommended.tooltip": "Based on evaluation runs for this agent", + "modelPicker.group.recommended.hint": "Based on evaluation runs for this agent", + "modelPicker.useCustomModel.subtitle": "Bring a model from your own connection", + "modelPicker.groupBy.category": "Category", + "modelPicker.tag.custom.label": "Custom", + "modelPicker.group.byo.label": "Custom Models (BYO)", + "modelPicker.tag.deprecating.label": "Deprecating {date}", + "modelPicker.group.deprecating.label": "Deprecating soon", + "modelPicker.row.editConnection": "Edit connection", + "modelPicker.groupBy.ariaLabel": "Group models by", + "modelPicker.loading.label": "Loading models", + "modelPicker.group.deprecating.hint": "Migrate before the usage end date", + "modelPicker.label.default": "Model", + "modelPicker.listbox.label": "Models", + "modelPicker.group.byo.hint": "Models you brought via your own connections", + "modelPicker.group.more.label": "More models", + "modelPicker.group.preview.hint": "Newer models in early access", + "modelPicker.group.other.label": "Other", + "modelPicker.tag.outOfRegion.label": "Out of region ({geography})", + "modelPicker.useCustomModel.disabledHint": "Pass onUseCustomModel to the picker to wire this action.", + "modelPicker.tag.preview.label": "Preview", + "modelPicker.group.preview.label": "Preview", + "modelPicker.groupBy.provider": "Provider", + "modelPicker.tag.recommended.label": "Recommended", + "modelPicker.group.recommended.label": "Recommended", + "modelPicker.row.removeConnection": "Remove connection", + "modelPicker.tag.substituted.label": "Routes to {target}", + "modelPicker.tag.outOfRegion.tooltip": "Routes traffic outside {homeRegion}", + "modelPicker.search.placeholder": "Search models", + "modelPicker.placeholder.selectAModel": "Select a model", + "modelPicker.tag.substituted.tooltip": "This model is retired. Your traffic is currently being routed to {target}. Update your configuration to make this explicit.", + "modelPicker.useCustomModel.title": "Use custom model", + "modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}" } diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx index 4c44f9429..40624f2e5 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/FolderSwitcher.tsx @@ -76,7 +76,7 @@ export const FolderSwitcher: React.FC = ({ setAnchorEl(open ? null : e.currentTarget)} focusRipple - aria-haspopup="listbox" + aria-haspopup="menu" aria-expanded={open} sx={{ display: 'inline-flex', diff --git a/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx b/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx index 4947b25bf..aa79bd4fa 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx +++ b/packages/apollo-react/src/material/components/ap-model-picker/primitives/GroupHeader.tsx @@ -156,6 +156,7 @@ export const GroupHeader: React.FC = ({ return ( { + if (!ctx) { + // Disabled: abort anything in flight and clear previous results so + // consumers don't keep rendering stale data / a stuck spinner. + abortRef.current?.abort(); + setModels([]); + setLoading(false); + setError(null); + return undefined; + } fetchModels(); return () => abortRef.current?.abort(); - }, [fetchModels]); + }, [ctx, fetchModels]); return { models, loading, error, refetch: fetchModels }; } @@ -90,16 +99,21 @@ export function useDiscoveryModels(ctx: DiscoveryRequestContext | null): UseDisc /** * The gateway emits PascalCase property names by default but the * Newtonsoft serializer in some product instances is configured for - * camelCase. Normalize so consumers can rely on camelCase regardless. + * camelCase. Normalize recursively so consumers can rely on camelCase + * regardless — nested DTOs (`ModelDetails.CostDetails`, + * `DeprecationDetails`, `RoutingDetails`, …) carry the fields the + * picker's chips and grouping read. */ +function camelizeDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(camelizeDeep); + if (!value || typeof value !== 'object') return value; + const camel: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + camel[k.charAt(0).toLowerCase() + k.slice(1)] = camelizeDeep(v); + } + return camel; +} + function normalizeKeys(list: unknown[]): DiscoveryModel[] { - return list.map((raw) => { - if (!raw || typeof raw !== 'object') return raw as DiscoveryModel; - const obj = raw as Record; - const camel: Record = {}; - for (const [k, v] of Object.entries(obj)) { - camel[k.charAt(0).toLowerCase() + k.slice(1)] = v; - } - return camel as unknown as DiscoveryModel; - }); + return list.map((raw) => camelizeDeep(raw) as DiscoveryModel); } diff --git a/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts b/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts index 418403d82..18267bfa5 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts +++ b/packages/apollo-react/src/material/components/ap-model-picker/useModelPickerState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { PickerTranslator } from './i18n'; import type { AnnotatedModel } from './primitives/OptionList'; @@ -77,7 +77,7 @@ export interface UseModelPickerStateResult { /** Toggle the collapsed state of a single group. */ toggleGroup: (groupKey: string) => void; /** The currently selected model, or null. */ - selected: AnnotatedModel | null; + selected: DiscoveryModel | null; /** * Raw `value` that couldn't be resolved against `models`. `null` when * the selection resolved cleanly, when no `value` was passed, or @@ -102,8 +102,6 @@ export interface UseModelPickerStateResult { searchRef: React.RefObject; } -let pickerIdCounter = 0; - /** * Shared state controller for ModelPicker (and any custom picker a * team builds on top of the primitives). Owns: @@ -130,10 +128,11 @@ export function useModelPickerState(opts: UseModelPickerStateOptions): UseModelP i18n, } = opts; - const id = useMemo(() => { - pickerIdCounter += 1; - return `apollo-model-picker-${pickerIdCounter}`; - }, []); + // React-owned id: stable across SSR/hydration and concurrent renders, + // unlike a module-level counter. The token is embedded in DOM ids and + // aria-* references only, where its ":" characters are valid. + const reactId = useId(); + const id = `apollo-model-picker-${reactId}`; const triggerRef = useRef(null); const searchRef = useRef(null); @@ -183,23 +182,24 @@ export function useModelPickerState(opts: UseModelPickerStateOptions): UseModelP return out; }, [annotated]); - // Selection lookup uses `models` (not visibleModels) so a stored - // selection a host filtered out still resolves — otherwise narrowing - // the catalog would silently flip the trigger into "unknown" state - // for legitimate, recently-removed-from-view selections. - const selected = useMemo( - () => annotated.find((m) => m.modelId === value) ?? null, - [annotated, value] + // Selection lookup prefers the visible (annotated) list but falls + // back to the raw catalog: a stored selection the host `filter` + // excluded must still resolve on the trigger — otherwise narrowing + // the catalog would silently blank the field for legitimate, + // recently-removed-from-view selections. `unknownValue` stays + // reserved for ids missing from the catalog entirely. + const selected = useMemo( + () => + annotated.find((m) => m.modelId === value) ?? models.find((m) => m.modelId === value) ?? null, + [annotated, models, value] ); const unknownValue = useMemo(() => { if (!value) return null; + // `selected` already falls back to the raw catalog, so any resolved + // value — visible or host-filtered — is "known". if (selected) return null; if (models.length === 0) return null; - // If `value` exists in the raw catalog but was filtered out by the - // host, we still treat that as "known" — render the placeholder - // rather than the red error state. The host opted into the filter. - if (models.some((m) => m.modelId === value)) return null; return value; }, [models, selected, value]); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts index ec63980cf..8eaaeddad 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts +++ b/packages/apollo-react/src/material/components/ap-model-picker/usePlatformAccess.ts @@ -127,9 +127,18 @@ export function useUserFolders(ctx: PlatformRequestContext | null): UseUserFolde }, [ctx]); useEffect(() => { + if (!ctx) { + // Disabled: abort anything in flight and clear previous results so + // a stale list / spinner doesn't linger after toggling off. + abortRef.current?.abort(); + setFolders([]); + setLoading(false); + setError(null); + return undefined; + } fetchFolders(); return () => abortRef.current?.abort(); - }, [fetchFolders]); + }, [ctx, fetchFolders]); return { folders, loading, error, refetch: fetchFolders }; } @@ -178,7 +187,12 @@ export function useCanManageByo(ctx: PlatformRequestContext | null): UseCanManag useEffect(() => { if (!ctx) { + // Disabled (explicit `canManageByo` override or no request + // context): clear every piece of state, not just the verdict. + abortRef.current?.abort(); setCanManage(undefined); + setLoading(false); + setError(null); return undefined; } abortRef.current?.abort(); diff --git a/packages/apollo-react/src/material/components/ap-model-picker/utils.ts b/packages/apollo-react/src/material/components/ap-model-picker/utils.ts index b1f95dbf9..6c27f026d 100644 --- a/packages/apollo-react/src/material/components/ap-model-picker/utils.ts +++ b/packages/apollo-react/src/material/components/ap-model-picker/utils.ts @@ -280,14 +280,15 @@ function vendorLabel(vendor: string): string { } function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString(undefined, { - month: 'short', - year: 'numeric', - }); - } catch { - return iso; - } + // `new Date()` never throws — bad input yields an Invalid Date that + // would stringify into the UI ("Deprecating Invalid Date"). Fall back + // to the raw value instead. + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }); } export type GroupStrategy = 'subscription' | 'vendor' | 'flat';