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
44 changes: 39 additions & 5 deletions web/src/sections/model-selector/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,20 @@ export interface ModelSelectorProps {
reasoningManager?: ReasoningManager;

disabled?: boolean;

/**
* When true, a "Global Default Model" entry is prepended to the list.
* Selecting it calls onChange with GLOBAL_DEFAULT_LLM_OPTION
* (modelConfigurationId === null), which callers should treat as "clear."
*/
includeGlobalDefault?: boolean;

/** Which side of the trigger the popover prefers to open on. */
side?: "top" | "bottom" | "left" | "right";

/** Optional controlled open state for the popover. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}

export default function ModelSelector({
Expand All @@ -64,6 +70,8 @@ export default function ModelSelector({
disabled = false,
includeGlobalDefault = false,
side = "top",
open: controlledOpen,
onOpenChange,
}: ModelSelectorProps) {
// Unscoped by default. An agent narrows the model list, but only a chat has
// an agent. The admin and settings pages that embed this picker have none,
Expand All @@ -76,16 +84,34 @@ export default function ModelSelector({
defaultText,
isLoading: allProvidersLoading,
} = useLLMProviders();

const llmProviders = providerOptions ?? allProviderOptions ?? [];
const isLoading = providerOptions === undefined && allProvidersLoading;
const [open, setOpen] = useState(false);

const [internalOpen, setInternalOpen] = useState(false);

const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;

const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange]
);

const scrollContainerRef = useRef<HTMLDivElement>(null);

// Resolve the currently selected option from the ID
const currentOption = useMemo(() => {
if (value == null || !llmProviders) return null;

for (const provider of llmProviders) {
const mc = provider.model_configurations.find((m) => m.id === value);

if (mc) {
return {
provider: provider.provider,
Expand All @@ -94,17 +120,24 @@ export default function ModelSelector({
};
}
}

return null;
}, [value, llmProviders]);

// When no model is explicitly selected, fall back to showing the global default.
const defaultModelOption = useMemo(() => {
if (!defaultText || !llmProviders) return null;
const provider = llmProviders.find((p) => p.id === defaultText.provider_id);

const provider = llmProviders.find(
(p) => p.id === defaultText.provider_id
);

const mc = provider?.model_configurations.find(
(m) => m.name === defaultText.model_name
);

if (!mc || !provider) return null;

return {
provider: provider.provider,
modelName: mc.name,
Expand All @@ -118,6 +151,7 @@ export default function ModelSelector({
const isSelected = useCallback(
(option: LLMOption) => {
if (option === GLOBAL_DEFAULT_LLM_OPTION) return value === null;

return option.modelConfigurationId != null
? option.modelConfigurationId === value
: option.provider === currentOption?.provider &&
Expand All @@ -129,9 +163,9 @@ export default function ModelSelector({
const handleSelect = useCallback(
(option: LLMOption) => {
onChange(option);
setOpen(false);
handleOpenChange(false);
},
[onChange]
[onChange, handleOpenChange]
);

const modelDetail = useModelDetailManagers(
Expand All @@ -144,7 +178,7 @@ export default function ModelSelector({
: getModelIcon("", "");

return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<div data-testid="llm-popover-trigger">
<Popover.Trigger asChild disabled={disabled}>
{renderTrigger ? (
Expand Down
61 changes: 52 additions & 9 deletions web/src/views/admin/LanguageModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,24 @@ interface ExistingProviderCardProps {
provider: LLMProviderView;
isDefault: boolean;
isLastProvider: boolean;
onBeforeOpen?: () => void;
}

function ExistingProviderCard({
provider,
isDefault,
isLastProvider,
onBeforeOpen,
}: ExistingProviderCardProps) {
const { mutate } = useSWRConfig();
const [isOpen, setIsOpen] = useState(false);
const deleteModal = useCreateModal();

const handleOpen = () => {
onBeforeOpen?.();
setIsOpen(true);
};

const handleDelete = async () => {
try {
await deleteLlmProvider(provider.id, isLastProvider);
Expand Down Expand Up @@ -178,7 +185,7 @@ function ExistingProviderCard({
state="filled"
padding={2}
rounding="lg"
onClick={() => setIsOpen(true)}
onClick={handleOpen}
>
<ContentAction
icon={icon}
Expand All @@ -200,6 +207,7 @@ function ExistingProviderCard({
aria-label={`Delete ${providerDisplayName(provider)}`}
onClick={(e) => {
e.stopPropagation();
onBeforeOpen?.();
deleteModal.toggle(true);
}}
/>
Expand All @@ -210,7 +218,7 @@ function ExistingProviderCard({
aria-label={`Edit ${providerDisplayName(provider)}`}
onClick={(e) => {
e.stopPropagation();
setIsOpen(true);
handleOpen();
}}
/>
</div>
Expand All @@ -229,21 +237,28 @@ function ExistingProviderCard({
interface NewProviderCardProps {
providerName: string;
isFirstProvider: boolean;
onBeforeOpen?: () => void;
}

function NewProviderCard({
providerName,
isFirstProvider,
onBeforeOpen,
}: NewProviderCardProps) {
const [isOpen, setIsOpen] = useState(false);
const { icon, productName, companyName, Modal } = getProvider(providerName);

const handleOpen = () => {
onBeforeOpen?.();
setIsOpen(true);
};

return (
<SelectCard
state="empty"
padding={2}
rounding="lg"
onClick={() => setIsOpen(true)}
onClick={handleOpen}
>
<ContentAction
icon={icon}
Expand All @@ -258,15 +273,18 @@ function NewProviderCard({
prominence="tertiary"
onClick={(e) => {
e.stopPropagation();
setIsOpen(true);
handleOpen();
}}
>
Connect
</Button>
}
/>
{isOpen && (
<Modal shouldMarkAsDefault={isFirstProvider} onOpenChange={setIsOpen} />
<Modal
shouldMarkAsDefault={isFirstProvider}
onOpenChange={setIsOpen}
/>
)}
</SelectCard>
);
Expand All @@ -278,25 +296,35 @@ function NewProviderCard({

interface NewCustomProviderCardProps {
isFirstProvider: boolean;
onBeforeOpen?: () => void;
}

function NewCustomProviderCard({
isFirstProvider,
onBeforeOpen,
}: NewCustomProviderCardProps) {
const [isOpen, setIsOpen] = useState(false);
const { icon, productName, companyName, Modal } = getProvider("custom");

const handleOpen = () => {
onBeforeOpen?.();
setIsOpen(true);
};

return (
<>
{isOpen && (
<Modal shouldMarkAsDefault={isFirstProvider} onOpenChange={setIsOpen} />
<Modal
shouldMarkAsDefault={isFirstProvider}
onOpenChange={setIsOpen}
/>
)}

<SelectCard
state="empty"
padding={2}
rounding="lg"
onClick={() => setIsOpen(true)}
onClick={handleOpen}
>
<ContentAction
icon={icon}
Expand All @@ -311,7 +339,7 @@ function NewCustomProviderCard({
prominence="tertiary"
onClick={(e) => {
e.stopPropagation();
setIsOpen(true);
handleOpen();
}}
>
Set Up
Expand All @@ -334,6 +362,8 @@ export default function LanguageModelsPage() {
const isConfigurationDisabled = usePHFeatureFlag(
PHFeatureFlag.LANGUAGE_MODEL_CONFIGURATION_DISABLED
);
const [isDefaultModelSelectorOpen, setIsDefaultModelSelectorOpen] =
useState(false);

// Resolve the current default to a model_configuration_id for ModelSelector
const defaultModelConfigId = useMemo(() => {
Expand Down Expand Up @@ -412,6 +442,8 @@ export default function LanguageModelsPage() {
}
}}
side="bottom"
open={isDefaultModelSelectorOpen}
onOpenChange={setIsDefaultModelSelectorOpen}
/>
</InputHorizontal>
</Card>
Expand Down Expand Up @@ -444,6 +476,9 @@ export default function LanguageModelsPage() {
provider={provider}
isDefault={defaultText?.provider_id === provider.id}
isLastProvider={sortedProviders.length === 1}
onBeforeOpen={() =>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
setIsDefaultModelSelectorOpen(false)
}
/>
))}
</div>
Expand Down Expand Up @@ -492,10 +527,18 @@ export default function LanguageModelsPage() {
key={name}
providerName={name}
isFirstProvider={isFirstProvider}
onBeforeOpen={() =>
setIsDefaultModelSelectorOpen(false)
}
/>
))}
{group.includeCustom && (
<NewCustomProviderCard isFirstProvider={isFirstProvider} />
<NewCustomProviderCard
isFirstProvider={isFirstProvider}
onBeforeOpen={() =>
setIsDefaultModelSelectorOpen(false)
}
/>
)}
</div>
</GeneralLayouts.Section>
Expand Down