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 @@ -25,9 +25,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
// 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',
flatCosts: { inputTokenCost: 300, outputTokenCost: 1500 },
},
},
},
Expand All @@ -40,9 +38,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
modelDetails: {
contextWindowTokens: 200000,
costDetails: {
inputTokenCost: 3.0,
outputTokenCost: 15.0,
currency: 'USD',
flatCosts: { inputTokenCost: 300, outputTokenCost: 1500 },
},
},
},
Expand All @@ -56,9 +52,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
modelDetails: {
contextWindowTokens: 1000000,
costDetails: {
inputTokenCost: 0.35,
outputTokenCost: 1.5,
currency: 'USD',
flatCosts: { inputTokenCost: 35, outputTokenCost: 150 },
},
},
},
Expand All @@ -74,9 +68,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
maxOutputTokens: 128000,
// gpt-5 large: premium
costDetails: {
inputTokenCost: 6.0,
outputTokenCost: 18.0,
currency: 'USD',
flatCosts: { inputTokenCost: 600, outputTokenCost: 1800 },
},
},
},
Expand All @@ -94,9 +86,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
contextWindowTokens: 128000,
maxOutputTokens: 16384,
costDetails: {
inputTokenCost: 2.5,
outputTokenCost: 10.0,
currency: 'USD',
flatCosts: { inputTokenCost: 250, outputTokenCost: 1000 },
},
},
},
Expand Down Expand Up @@ -189,9 +179,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
maxOutputTokens: 32768,
// gpt-4.1-mini: basic
costDetails: {
inputTokenCost: 0.4,
outputTokenCost: 1.6,
currency: 'USD',
flatCosts: { inputTokenCost: 40, outputTokenCost: 160 },
},
},
},
Expand All @@ -213,9 +201,7 @@ const MOCK_MODELS: DiscoveryModel[] = [
contextWindowTokens: 400000,
maxOutputTokens: 128000,
costDetails: {
inputTokenCost: 2.5,
outputTokenCost: 10.0,
currency: 'USD',
flatCosts: { inputTokenCost: 250, outputTokenCost: 1000 },
},
},
},
Expand Down Expand Up @@ -377,6 +363,30 @@ export const AdminCanManageByo: Story = {
},
};

export const DeleteWithConfirmation: Story = {
name: 'Admin: delete asks for confirmation',
render: Controlled,
args: {
variant: 'searchable',
models: MOCK_MODELS,
canManageByo: true,
onDeleteModel: (model: DiscoveryModel) => {
// eslint-disable-next-line no-console
console.log('[story] confirmed delete of', model.modelId);
},
},
parameters: {
docs: {
description: {
story:
'Pass `onDeleteModel` and BYO rows gain a delete action. The picker ' +
'always shows a confirmation dialog naming the configuration before ' +
'invoking the handler — deletion affects every consumer in the tenant.',
},
},
},
};

export const ViewerCannotManageByo: Story = {
name: 'Viewer: read-only BYO',
render: Controlled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe('<ModelPicker>', () => {
}
});

it('renders a delete row action on BYO rows when onDeleteModel is provided', async () => {
it('delete row action asks for confirmation before invoking onDeleteModel', async () => {
const user = userEvent.setup();
const onDeleteModel = vi.fn();
renderPicker(
Expand All @@ -267,9 +267,18 @@ describe('<ModelPicker>', () => {
);
await user.click(screen.getByRole('button', { expanded: false }));
const deleteButton = await screen.findByRole('button', { name: /delete configuration/i });

// Cancel: nothing happens.
await user.click(deleteButton);
await user.click(await screen.findByRole('button', { name: /cancel/i }));
expect(onDeleteModel).not.toHaveBeenCalled();

// Confirm: the host handler runs with the BYO row. Opening the dialog
// closes the popup, so reopen it once the dialog's aria-hidden lifts.
await user.click(await screen.findByRole('button', { expanded: false }));
await user.click(await screen.findByRole('button', { name: /delete configuration/i }));
await user.click(await screen.findByRole('button', { name: /^delete$/i }));
expect(onDeleteModel).toHaveBeenCalledTimes(1);
// Called with the BYO model row.
expect(onDeleteModel.mock.calls[0][0]).toMatchObject({ modelSubscriptionType: 'BYOMAdded' });
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import AddIcon from '@mui/icons-material/Add';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import ButtonBase from '@mui/material/ButtonBase';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import Typography from '@mui/material/Typography';
import { Colors, FontFamily } from '@uipath/apollo-core';
import React from 'react';
import { useSafeLingui } from '../../../i18n';
import { DELETE_CONFIRM } from './i18n';
import type { ModelBadgeKind } from './badges';
import { FolderSwitcher, type FolderSwitcherFolder } from './primitives/FolderSwitcher';
import { defaultRowActions } from './primitives/ModelOptionRow';
Expand Down Expand Up @@ -424,14 +431,20 @@ export const ModelPicker = React.forwardRef<HTMLButtonElement, ModelPickerProps>

// In self-fetch mode a BYO delete refetches the catalog once the
// host's handler resolves; with host-owned models the host refreshes.
// Deleting a configuration affects every consumer in the tenant, so the
// picker always confirms before invoking the host's handler.
const [pendingDelete, setPendingDelete] = React.useState<DiscoveryModel | null>(null);
const handleDeleteModel = React.useMemo(() => {
if (!onDeleteModel) return undefined;
if (!selfFetchCtx) return onDeleteModel;
return async (m: DiscoveryModel) => {
await onDeleteModel(m);
refetchDiscovery();
};
}, [onDeleteModel, selfFetchCtx, refetchDiscovery]);
return (m: DiscoveryModel) => setPendingDelete(m);
}, [onDeleteModel]);
const confirmPendingDelete = React.useCallback(async () => {
const model = pendingDelete;
setPendingDelete(null);
if (!model) return;
await onDeleteModel?.(model);
if (selfFetchCtx) refetchDiscovery();
}, [pendingDelete, onDeleteModel, selfFetchCtx, refetchDiscovery]);

// BYO rows without a host-supplied `byoConnectionLabel` get their
// Integration Service connection name resolved by the picker itself.
Expand Down Expand Up @@ -896,6 +909,56 @@ export const ModelPicker = React.forwardRef<HTMLButtonElement, ModelPickerProps>
</>
)}
</PickerPopup>

<Dialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
container={popupContainer}
disablePortal={disablePortal}
maxWidth="xs"
// Destructive interrupt: announce as an alert dialog wired to its
// own title and description. Visuals are deliberately left to the
// host's MUI theme so the dialog matches the product's other
// confirmation dialogs.
role="alertdialog"
aria-labelledby={`${id}-delete-title`}
aria-describedby={`${id}-delete-description`}
sx={{ zIndex: 1500 }}
PaperProps={{ sx: { fontFamily: FontFamily.FontNormal } }}
>
<DialogTitle id={`${id}-delete-title`} sx={{ fontFamily: 'inherit' }}>
{_(DELETE_CONFIRM.title)}
</DialogTitle>
<DialogContent>
<DialogContentText id={`${id}-delete-description`} sx={{ fontFamily: 'inherit' }}>
{_({
...DELETE_CONFIRM.message,
values: {
name: pendingDelete ? (pendingDelete.displayName ?? pendingDelete.modelName) : '',
},
})}
</DialogContentText>
</DialogContent>
<DialogActions>
{/* Focus lands on the safe action for a destructive confirm. */}
<Button
autoFocus
color="secondary"
disableElevation
onClick={() => setPendingDelete(null)}
>
{_(DELETE_CONFIRM.cancel)}
</Button>
<Button
color="error"
variant="contained"
disableElevation
onClick={confirmPendingDelete}
>
{_(DELETE_CONFIRM.confirm)}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Resolution order: `recommendedModelIds` prop (test/storybook override) → DTO `

### 5. Cost badges

Cost badges are **on by default**: whenever the Discovery DTO carries cost data, each row gets its tier from the Apollo pool (`cost-basic` / `cost-standard` / `cost-premium`) via `defaultCostTier`, which bins `modelDetails.costDetails.inputTokenCost` (USD per million input tokens) at `$1` / `$5`. No wiring needed.
Cost badges are **on by default**: whenever the Discovery DTO carries cost data, each row gets its tier from the Apollo pool (`cost-basic` / `cost-standard` / `cost-premium`) via `defaultCostTier`, which bins `modelDetails.costDetails.flatCosts.inputTokenCost` (cents per million input tokens, the gateway's unit; the first tier is used for tiered pricing) at `$1`/M and `$5`/M. No wiring needed.

A host-supplied `badgesFor` takes over entirely — return your own pool kinds to reclassify, or `[]` to suppress badges:

Expand Down Expand Up @@ -185,7 +185,7 @@ Under the hood: `GET {baseUrl}/portal_/api/organization/UserOrganizationInfo`

- The **"Use custom model" footer** opens the *add configuration* form, deep-linked to `/{tenantId}/{folderId}/add` and pre-populated via `?product=&feature=` from `requestingProduct`/`requestingFeature`. The folder id is the switcher's current selection, or — when none is selected ("All folders") — the first available folder, matching the configurations page's own default. Only when no folders exist does it fall back to the configurations list.
- The **edit row action** (BYO rows only) opens the configuration's *edit* form when the model carries `byomDetails.byoConfigurationId` (served by Discovery on UiPath/Arima#2659); when absent it lands on the configurations list scoped to the tenant + folder.
- The **delete row action** (BYO rows only) is opt-in: pass `onDeleteModel` and the picker renders a delete icon next to edit, calling your handler (which performs the deletion and refreshes `models`). Omit it to keep removal on the configurations page only.
- The **delete row action** (BYO rows only) is opt-in: pass `onDeleteModel` and the picker renders a delete icon next to edit. The picker always asks for confirmation first (a built-in dialog naming the configuration), then calls your handler; in self-fetch mode it refetches Discovery once your handler resolves. Omit it to keep removal on the configurations page only.

These navigations always open the AI Trust Layer pages in a new browser tab - the picker is embedded in a product surface, and navigating it away would unload the user's in-progress work. `onUseCustomModel` overrides the footer's default navigation (e.g. an in-app wizard), and `slots.optionActions` overrides the row actions. Products with their own authorization model can pass `canManageByo` (`true`/`false`); when set, no admin check request is made.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ export const ROW_ACTIONS = {
}),
} as const;

/* ──────────────────────────────────────────────────────────────────────
* Delete confirmation
* ─────────────────────────────────────────────────────────────────── */

export const DELETE_CONFIRM = {
title: msg({
id: 'modelPicker.deleteConfirm.title',
message: 'Delete custom model',
}),
message: msg({
id: 'modelPicker.deleteConfirm.message',
message:
'This permanently deletes the "{name}" configuration for everyone in this tenant. This action cannot be undone.',
}),
cancel: msg({
id: 'modelPicker.deleteConfirm.cancel',
message: 'Cancel',
}),
confirm: msg({
id: 'modelPicker.deleteConfirm.confirm',
message: 'Delete',
}),
} as const;

/* ──────────────────────────────────────────────────────────────────────
* Footer CTA
* ─────────────────────────────────────────────────────────────────── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,9 @@
"modelPicker.useCustomModel.title": "Use custom model",
"modelPicker.tag.deprecating.tooltip": "Will be replaced by {replacement}",
"modelPicker.empty.noMatch": "No models match \"{query}\".",
"modelPicker.empty.noModels": "No models available."
"modelPicker.empty.noModels": "No models available.",
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
{}
{
"modelPicker.deleteConfirm.title": "Delete custom model",
"modelPicker.deleteConfirm.message": "This permanently deletes the \"{name}\" configuration for everyone in this tenant. This action cannot be undone.",
"modelPicker.deleteConfirm.cancel": "Cancel",
"modelPicker.deleteConfirm.confirm": "Delete"
}
Loading
Loading