Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {
Expand All @@ -58,14 +61,37 @@ 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;

if (stepId === 'deploy-to-gateway') {
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,
Expand All @@ -74,7 +100,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
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
const stepBannerRefreshTrigger = useMemo(() => {
if (isLoadingDeployments) return 'loading';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -218,6 +221,8 @@ const tabs = [
'Models',
];

const API_KEY_HIGHLIGHT_DURATION_MS = 3000;

type RateLimitingDraftActions = {
saveDraftChanges: () => Promise<boolean>;
discardDraftChanges: () => void;
Expand Down Expand Up @@ -539,6 +544,57 @@ function ServiceProviderOverviewContent() {
};

const [highlightApiKeySection, setHighlightApiKeySection] = useState(false);
const apiKeysSectionNodeRef = useRef<HTMLDivElement | null>(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<string | null>(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;
Expand Down Expand Up @@ -618,9 +674,7 @@ function ServiceProviderOverviewContent() {
);
navigate(deployPath);
} else if (stepId === 'consume') {
setTabIndex(0);
setHighlightApiKeySection(true);
setTimeout(() => setHighlightApiKeySection(false), 3000);
focusApiKeysSection();
}
};

Expand Down Expand Up @@ -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<string, unknown> = { ...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;
Expand Down Expand Up @@ -1586,6 +1673,7 @@ function ServiceProviderOverviewContent() {
setStepBannerRefreshTrigger((prev) => prev + 1)
}
highlightApiKeySection={highlightApiKeySection}
apiKeysSectionRef={registerApiKeysSection}
onCreateProxy={handleCreateProxyClick}
onBlockedNavigation={handleDeployNavigation}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -128,13 +135,15 @@ function buildApiKeyResourceName(displayName: string): string {
type ServiceProviderOverviewTabProps = {
onApiKeyCreated?: () => void;
highlightApiKeySection?: boolean;
apiKeysSectionRef?: Ref<HTMLDivElement>;
onCreateProxy?: () => void;
onBlockedNavigation?: () => void;
};

export default function ServiceProviderOverviewTab({
onApiKeyCreated,
highlightApiKeySection,
apiKeysSectionRef,
onCreateProxy,
onBlockedNavigation,
}: ServiceProviderOverviewTabProps) {
Expand Down Expand Up @@ -852,7 +861,7 @@ export default function ServiceProviderOverviewTab({
</Grid>
</Stack>
<Divider />
<Box>
<Box ref={apiKeysSectionRef}>
<Typography variant="h6" sx={{ mb: 1.5, fontWeight: 600 }}>
<FormattedMessage
id="aiWorkspace.pages.appShell.appShellPages.serviceProvider.ServiceProviderDeploymentsCard.api.keys"
Expand Down