From 0e677f5c272e68df7382a96bd25c1195373c14f6 Mon Sep 17 00:00:00 2001 From: Sanjula Herath Date: Mon, 3 Aug 2026 23:47:00 +0530 Subject: [PATCH 1/2] fix(ai-workspace): scroll API Keys into view from consume step Focus the API Keys section when the consume step is selected from either the overview or deploy page. Carry the intent through router state and consume it once per location to prevent effect replays. Related to #3099 --- .../serviceProvider/ServiceProviderDeploy.tsx | 8 +- .../ServiceProviderOverview.tsx | 100 ++++++++++++++++-- .../ServiceProviderOverviewTab.tsx | 13 ++- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx index 9e69bd2ed4..0624bb891e 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx @@ -74,7 +74,13 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout ) : buildOrgPath(currentOrganization, `/service-provider/${providerId}`); - navigate(overviewPath); + // The API Keys section lives on the overview page, so the consume step + // carries a one-time focus intent that the overview page consumes and + // clears on arrival. + navigate( + overviewPath, + stepId === 'consume' ? { state: { focusApiKeys: true } } : undefined + ); }; const stepBannerRefreshTrigger = useMemo(() => { if (isLoadingDeployments) return 'loading'; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx index 65a186795b..20101d6f4c 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverview.tsx @@ -186,6 +186,9 @@ function parseOpenApiSpec(text: string): OpenApiSpec | null { type LocationState = { providerAdded?: boolean; + // One-time intent set by the "Consume LLM Provider" step on the deploy page. + // Consumed and cleared on arrival so a refresh doesn't re-trigger the scroll. + focusApiKeys?: boolean; }; type ProxyCreationNavigationState = { @@ -218,6 +221,8 @@ const tabs = [ 'Models', ]; +const API_KEY_HIGHLIGHT_DURATION_MS = 3000; + type RateLimitingDraftActions = { saveDraftChanges: () => Promise; discardDraftChanges: () => void; @@ -539,6 +544,57 @@ function ServiceProviderOverviewContent() { }; const [highlightApiKeySection, setHighlightApiKeySection] = useState(false); + const apiKeysSectionNodeRef = useRef(null); + // Set when a focus is requested before the section is mounted. The section is + // behind both the Overview tab panel (which unmounts inactive tabs) and an + // async `gateways.length > 0` guard, so on a tab switch or a fresh navigation + // the node doesn't exist yet. The callback ref below drains this flag as soon + // as it mounts, so no timeout or polling is needed. + const pendingApiKeysFocusRef = useRef(false); + // Last history entry whose navigation state was consumed, so an effect replay + // (React Strict Mode) can't re-run the snackbar or the scroll for that entry. + const processedNavigationStateKeyRef = useRef(null); + + // Scrolls to the section if it is mounted; otherwise the intent stays pending + // for `registerApiKeysSection` to pick up once it does mount. + const focusApiKeysNode = useCallback(() => { + const node = apiKeysSectionNodeRef.current; + if (!node) return; + // Cleared before scrolling so a single intent can never scroll twice + // (relevant under React Strict Mode's double effect/ref invocation). + pendingApiKeysFocusRef.current = false; + setHighlightApiKeySection(true); + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, []); + + // Stable callback ref: React invokes it exactly when the section mounts or + // unmounts, making this render-aware rather than time-based. + const registerApiKeysSection = useCallback( + (node: HTMLDivElement | null) => { + apiKeysSectionNodeRef.current = node; + if (node && pendingApiKeysFocusRef.current) { + focusApiKeysNode(); + } + }, + [focusApiKeysNode] + ); + + const focusApiKeysSection = useCallback(() => { + setTabIndex(0); // API Keys live on the Overview tab. + pendingApiKeysFocusRef.current = true; + focusApiKeysNode(); + }, [focusApiKeysNode]); + + // Auto-clears the highlight and owns its own timer cleanup, so an unmount or + // a Strict Mode remount can't leave a stray timeout behind. + useEffect(() => { + if (!highlightApiKeySection) return; + const timer = setTimeout( + () => setHighlightApiKeySection(false), + API_KEY_HIGHLIGHT_DURATION_MS + ); + return () => clearTimeout(timer); + }, [highlightApiKeySection]); const handleDeleteConfirm = async () => { if (!deleteTarget || isDeletingProvider) return; @@ -618,9 +674,7 @@ function ServiceProviderOverviewContent() { ); navigate(deployPath); } else if (stepId === 'consume') { - setTabIndex(0); - setHighlightApiKeySection(true); - setTimeout(() => setHighlightApiKeySection(false), 3000); + focusApiKeysSection(); } }; @@ -653,11 +707,44 @@ function ServiceProviderOverviewContent() { useEffect(() => { const state = location.state as LocationState | null; - if (state?.providerAdded) { + if (!state?.providerAdded && !state?.focusApiKeys) return; + // The replace navigation below is async, so a replayed effect still sees this + // state. Claim the entry first — both runs then resolve to a single pass. + if (processedNavigationStateKeyRef.current === location.key) return; + processedNavigationStateKeyRef.current = location.key; + + if (state.providerAdded) { showSnackbar('Successfully added new service provider.', 'success'); - navigate(location.pathname, { replace: true, state: null }); } - }, [location.pathname, location.state, navigate]); + if (state.focusApiKeys) { + focusApiKeysSection(); + } + + // Drop only the consumed flags so unrelated router state survives, and keep + // search/hash so replacing the entry doesn't discard query parameters. + const remainingState: Record = { ...state }; + delete remainingState.providerAdded; + delete remainingState.focusApiKeys; + navigate( + { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }, + { + replace: true, + state: Object.keys(remainingState).length > 0 ? remainingState : null, + } + ); + }, [ + location.key, + location.pathname, + location.search, + location.hash, + location.state, + navigate, + focusApiKeysSection, + ]); useEffect(() => { if (hasUnsavedChanges) return; @@ -1586,6 +1673,7 @@ function ServiceProviderOverviewContent() { setStepBannerRefreshTrigger((prev) => prev + 1) } highlightApiKeySection={highlightApiKeySection} + apiKeysSectionRef={registerApiKeysSection} onCreateProxy={handleCreateProxyClick} onBlockedNavigation={handleDeployNavigation} /> diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx index 4c7358a361..c229abaedf 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx @@ -16,7 +16,14 @@ * under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Ref, +} from 'react'; import { useNavigate } from 'react-router-dom'; import { Alert, @@ -128,6 +135,7 @@ function buildApiKeyResourceName(displayName: string): string { type ServiceProviderOverviewTabProps = { onApiKeyCreated?: () => void; highlightApiKeySection?: boolean; + apiKeysSectionRef?: Ref; onCreateProxy?: () => void; onBlockedNavigation?: () => void; }; @@ -135,6 +143,7 @@ type ServiceProviderOverviewTabProps = { export default function ServiceProviderOverviewTab({ onApiKeyCreated, highlightApiKeySection, + apiKeysSectionRef, onCreateProxy, onBlockedNavigation, }: ServiceProviderOverviewTabProps) { @@ -852,7 +861,7 @@ export default function ServiceProviderOverviewTab({ - + Date: Tue, 4 Aug 2026 00:15:43 +0530 Subject: [PATCH 2/2] fix(ai-workspace): gate consume focus on API Keys availability Only pass the API Keys focus intent when the overview page can render its target. Otherwise, keep the user on the deploy page and show an informational message explaining why API key management is unavailable. Related to #3099 --- .../serviceProvider/ServiceProviderDeploy.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx index 0624bb891e..da1a87adec 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploy.tsx @@ -38,7 +38,7 @@ import { useGatewayDeploy, } from '../../../../contexts/GatewayDeployContext'; import { GatewayDeployMainSection } from '../../../../Components/GatewayDeploy'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import LLLMStepBanner, { type LLLMStepBannerStepId, } from '../quickStart/lllmStepBanner'; @@ -47,6 +47,9 @@ import { buildProjectPath, } from '../../../../utils/projectRouting'; import { useAppShell } from '../../../../contexts/AppShellContext'; +import { useAppAuth } from '../../../../contexts/AppAuthContext'; +import { SCOPES } from '../../../../auth/permissions'; +import useAIWorkspaceSnackbar from '../../../../hooks/aiWorkspaceSnackbar'; import { AIEntityProvider } from '../../../../contexts/AIEntitiesContext'; type ServiceProviderDeployLayoutProps = { @@ -58,7 +61,15 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout const { provider } = useLLMProvider(); const { deployments, isLoadingDeployments } = useGatewayDeploy(); const { currentOrganization, currentProject } = useAppShell(); + const { hasPermission } = useAppAuth(); + const showSnackbar = useAIWorkspaceSnackbar(); + const intl = useIntl(); const isProjectLevel = Boolean(currentProject?.id); + // Mirrors `isAdminOrgLevel` on the overview page: it only renders the tabbed + // layout containing the API Keys section for org-level callers holding + // provider management permission. + const canManageApiKeys = + hasPermission(SCOPES.LLM_PROVIDER_MANAGE) && !isProjectLevel; const handleLLLMStepBannerClick = (stepId: LLLMStepBannerStepId) => { if (!providerId) return; @@ -66,6 +77,21 @@ function ServiceProviderDeployLayout({ providerId }: ServiceProviderDeployLayout return; } + if (stepId === 'consume' && !canManageApiKeys) { + // The overview page renders no API Keys section in this context, so + // navigating there would look like a dead button. Explain instead, and + // don't create a focus intent that nothing can honour. + showSnackbar( + intl.formatMessage({ + id: 'aiWorkspace.pages.appShell.appShellPages.serviceProvider.ServiceProviderDeploy.api.key.management.unavailable', + defaultMessage: + 'API key management is available only at the organization level with LLM provider management permission.', + }), + 'info' + ); + return; + } + const overviewPath = isProjectLevel ? buildProjectPath( currentOrganization,