fix(cloud): Tighten cloud compatibility truthfulness

Keep preserved cloud-incompatible selections visible with inline warnings
and make model empty states follow the filtered cloud-compatible set.

Add a cloud default override for SearXNG and update the built component
index so non-dev startup matches cloud mode behavior.
This commit is contained in:
Jordan Frazier
2026-03-25 15:34:25 -04:00
parent fb975d3827
commit 097fc7500c
10 changed files with 207 additions and 25 deletions

View File

@ -523,6 +523,43 @@ describe("ModelInputComponent", () => {
mockCloudOnly = false;
});
it("should show a cloud-specific empty state when every provider is filtered out", async () => {
mockCloudOnly = true;
const user = userEvent.setup();
const handleOnNewValue = jest.fn();
renderWithQueryClient(
<ModelInputComponent
{...defaultProps}
options={[
{
id: "llama3",
name: "llama3",
icon: "Ollama",
provider: "Ollama",
metadata: {},
},
]}
value={[]}
handleOnNewValue={handleOnNewValue}
/>,
);
expect(
screen.getByText("No cloud-compatible models"),
).toBeInTheDocument();
expect(handleOnNewValue).not.toHaveBeenCalled();
await user.click(screen.getByRole("combobox"));
await waitFor(() => {
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
});
expect(screen.queryByTestId("llama3-option")).not.toBeInTheDocument();
mockCloudOnly = false;
});
it("should show all providers when cloud mode is inactive", () => {
mockCloudOnly = false;

View File

@ -4,12 +4,12 @@ import { Button } from "@/components/ui/button";
import { PopoverTrigger } from "@/components/ui/popover";
import { RECEIVING_INPUT_VALUE } from "@/constants/constants";
import { cn } from "@/utils/utils";
import { ModelOption, SelectedModel } from "../types";
import type { SelectedModel } from "../types";
interface ModelTriggerProps {
open: boolean;
disabled: boolean;
options: ModelOption[];
visibleOptionsCount: number;
selectedModel: SelectedModel | null;
showCloudIncompatibleWarning?: boolean;
placeholder?: string;
@ -18,12 +18,13 @@ interface ModelTriggerProps {
id: string;
refButton: RefObject<HTMLButtonElement | null>;
showEmptyState?: boolean;
emptyStateLabel?: string;
}
const ModelTrigger = ({
open,
disabled,
options,
visibleOptionsCount,
selectedModel,
showCloudIncompatibleWarning = false,
placeholder = "Setup Provider",
@ -32,9 +33,12 @@ const ModelTrigger = ({
id,
refButton,
showEmptyState = false,
emptyStateLabel = "No models enabled",
}: ModelTriggerProps) => {
const hasVisibleOptions = visibleOptionsCount > 0;
const renderSelectedIcon = () => {
if (disabled || options.length === 0) {
if (disabled) {
return null;
}
@ -47,9 +51,9 @@ const ModelTrigger = ({
};
// Check if we're in empty state mode (showEmptyState=true and no options)
const isEmptyStateMode = showEmptyState && options.length === 0;
const isEmptyStateMode = showEmptyState && !hasVisibleOptions;
if (!hasEnabledProviders && !showEmptyState && options.length === 0) {
if (!hasEnabledProviders && !showEmptyState && !hasVisibleOptions) {
return (
<Button
variant="default"
@ -67,7 +71,10 @@ const ModelTrigger = ({
<div className="flex w-full flex-col">
<PopoverTrigger asChild>
<Button
disabled={disabled || (options.length === 0 && !showEmptyState)}
disabled={
disabled ||
(!hasVisibleOptions && !isEmptyStateMode && !selectedModel)
}
variant="primary"
size="xs"
role="combobox"
@ -89,7 +96,7 @@ const ModelTrigger = ({
RECEIVING_INPUT_VALUE
) : isEmptyStateMode ? (
<div className="truncate text-muted-foreground">
No models enabled
{emptyStateLabel}
</div>
) : (
<div className="flex min-w-0 flex-col items-start">

View File

@ -162,6 +162,16 @@ export default function ModelInputComponent({
[cloudOnly, selectedModel?.provider],
);
const showNoCompatibleCloudModels = useMemo(
() => cloudOnly && options.length > 0 && flatOptions.length === 0 && !selectedModel,
[cloudOnly, options.length, flatOptions.length, selectedModel],
);
const effectiveShowEmptyState = showEmptyState || showNoCompatibleCloudModels;
const emptyStateLabel = showNoCompatibleCloudModels
? "No cloud-compatible models"
: "No models enabled";
useEffect(() => {
// Only proceed if we have options and haven't selected a value
if (flatOptions.length > 0 && (!value || value.length === 0)) {
@ -333,7 +343,7 @@ export default function ModelInputComponent({
// Loading state (skip if showEmptyState is true - we want to show the empty dropdown instead)
if (
((!options || options.length === 0) && !showEmptyState) ||
((!options || options.length === 0) && !effectiveShowEmptyState) ||
isRefreshingAfterClose ||
refreshOptions
) {
@ -348,7 +358,7 @@ export default function ModelInputComponent({
<ModelTrigger
open={open}
disabled={disabled}
options={options}
visibleOptionsCount={flatOptions.length}
selectedModel={selectedModel}
showCloudIncompatibleWarning={showCloudIncompatibleWarning}
placeholder={placeholder}
@ -356,7 +366,8 @@ export default function ModelInputComponent({
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
id={id}
refButton={refButton}
showEmptyState={showEmptyState}
showEmptyState={effectiveShowEmptyState}
emptyStateLabel={emptyStateLabel}
/>
</div>
{renderPopoverContent()}

View File

@ -14,6 +14,7 @@ type SortableListComponentProps = {
helperText?: string;
helperMetadata?: any;
options?: any[];
cloudIncompatibleOptions?: unknown[];
searchCategory?: string[];
icon?: string;
limit?: number;
@ -25,16 +26,23 @@ const SortableListItem = memo(
index,
onRemove,
limit = 1,
showCloudIncompatibleWarning = false,
}: {
data: any;
index: number;
onRemove: () => void;
limit?: number;
showCloudIncompatibleWarning?: boolean;
}) => (
<li
className={cn(
"inline-flex h-12 w-full items-center gap-2 text-sm font-medium",
limit === 1 ? "h-6 rounded-md bg-muted" : "group cursor-grab",
"inline-flex w-full items-center gap-2 text-sm font-medium",
limit === 1
? cn(
"rounded-md bg-muted",
showCloudIncompatibleWarning ? "min-h-10 py-1" : "h-6",
)
: "group min-h-12 cursor-grab",
)}
>
{limit !== 1 && (
@ -51,14 +59,22 @@ const SortableListItem = memo(
</div>
)}
<span
className={cn(
"truncate text-xxs font-medium text-muted-foreground",
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
<div className="flex min-w-0 flex-1 flex-col">
<span
className={cn(
"truncate text-xxs font-medium text-muted-foreground",
limit === 1 ? "max-w-56 pl-2" : "max-w-48",
)}
>
{data.name}
</span>
{showCloudIncompatibleWarning && (
<div className="flex items-center gap-1 pl-2 text-[11px] text-accent-emerald-foreground">
<ForwardedIconComponent name="CloudOff" className="h-3 w-3" />
<span>Not available in cloud</span>
</div>
)}
>
{data.name}
</span>
</div>
</div>
<Button
size="icon"
@ -84,6 +100,7 @@ const SortableListComponent = ({
helperText = "",
helperMetadata = { icon: undefined, variant: "muted-foreground" },
options = [],
cloudIncompatibleOptions = [],
searchCategory = [],
limit,
id,
@ -96,6 +113,18 @@ const SortableListComponent = ({
// Convert value to an array if it exists, otherwise use empty array
const listData = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const isCloudIncompatibleOption = useCallback(
(item: unknown) => {
const itemName =
typeof item === "object" && item !== null && "name" in item
? (item as { name?: unknown }).name ?? item
: item;
return cloudIncompatibleOptions.some((option) => option === itemName);
},
[cloudIncompatibleOptions],
);
const createRemoveHandler = useCallback(
(index: number) => () => {
const newList = listData.filter((_, i) => i !== index);
@ -195,6 +224,7 @@ const SortableListComponent = ({
index={index}
onRemove={createRemoveHandler(index)}
limit={limit}
showCloudIncompatibleWarning={isCloudIncompatibleOption(data)}
/>
))}
</ReactSortable>

View File

@ -1,4 +1,4 @@
import { render, waitFor } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import * as ReactSortableModule from "react-sortablejs";
import SortableListComponent from "./index";
@ -56,4 +56,27 @@ describe("SortableListComponent reproduction", () => {
expect(handleOnNewValue).not.toHaveBeenCalled();
});
it("should mark preserved cloud-incompatible selections", () => {
render(
<SortableListComponent
tooltip=""
name="storage_location"
value={[{ name: "Local" }]}
handleOnNewValue={jest.fn()}
disabled={false}
recommended={false}
placeholder="Select items"
isList={true}
fileTypes={[]}
onDelete={jest.fn()}
id="test-id"
limit={1}
cloudIncompatibleOptions={["Local"]}
/>,
);
expect(screen.getByText("Local")).toBeInTheDocument();
expect(screen.getByText("Not available in cloud")).toBeInTheDocument();
});
});

View File

@ -11,6 +11,11 @@ type StrRenderProps = {
value?: string | number | readonly string[] | null;
placeholder?: string | null;
};
type SortableListRenderProps = {
value?: Array<{ name?: string }>;
options?: Array<{ name?: string }>;
cloudIncompatibleOptions?: unknown[];
};
jest.mock("@/stores/cloudModeStore", () => ({
useCloudModeStore: <T,>(selector: (state: CloudModeState) => T) =>
@ -31,6 +36,22 @@ jest.mock("./components/mcpComponent", () => () => (
<div data-testid="mcp-component" />
));
jest.mock("./components/sortableListComponent", () => ({
__esModule: true,
default: ({
value = [],
options = [],
cloudIncompatibleOptions = [],
}: SortableListRenderProps) => (
<div
data-testid="sortable-list-props"
data-value={value.map((option) => option.name ?? "").join(",")}
data-options={options.map((option) => option.name ?? "").join(",")}
data-cloud-incompatible={cloudIncompatibleOptions.join(",")}
/>
),
}));
describe("ParameterRenderComponent", () => {
beforeEach(() => {
mockCloudOnly = false;
@ -92,4 +113,35 @@ describe("ParameterRenderComponent", () => {
"Enter your cloud URL",
);
});
it("preserves incompatible sortable values while filtering them from the chooser in cloud mode", () => {
mockCloudOnly = true;
render(
<ParameterRenderComponent
{...baseProps}
name="storage_location"
templateValue={[{ name: "Local" }]}
templateData={{
type: "sortableList",
name: "storage_location",
options: [{ name: "Local" }, { name: "AWS" }],
limit: 1,
}}
nodeClass={{
...baseProps.nodeClass,
metadata: {
cloud_incompatible_options: {
storage_location: ["Local"],
},
},
}}
/>,
);
const renderedProps = screen.getByTestId("sortable-list-props");
expect(renderedProps).toHaveAttribute("data-value", "Local");
expect(renderedProps).toHaveAttribute("data-options", "AWS");
expect(renderedProps).toHaveAttribute("data-cloud-incompatible", "Local");
});
});

View File

@ -298,15 +298,16 @@ export function ParameterRenderComponent({
case "sortableList": {
// Filter out cloud-incompatible options when cloud mode is active
let sortableOptions = templateData?.options;
let cloudIncompatibleOptions: unknown[] = [];
if (cloudOnly && nodeMetadata?.cloud_incompatible_options) {
const incompatible = nodeMetadata.cloud_incompatible_options[name];
if (incompatible && Array.isArray(incompatible)) {
cloudIncompatibleOptions = incompatible;
sortableOptions = sortableOptions?.filter((opt: unknown) => {
const optionName =
typeof opt === "object" && opt !== null && "name" in opt
? ((opt as { name?: unknown }).name ?? opt)
: opt;
return !incompatible.includes(optionName);
});
}
@ -317,6 +318,7 @@ export function ParameterRenderComponent({
helperText={templateData?.helper_text}
helperMetadata={templateData?.helper_text_metadata}
options={sortableOptions}
cloudIncompatibleOptions={cloudIncompatibleOptions}
searchCategory={templateData?.search_category}
limit={templateData?.limit}
id={`sortablelist_${id}`}

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,11 @@ class SearXNGToolComponent(LCToolComponent):
description = "A component that searches for tools using SearXNG."
name = "SearXNGTool"
legacy: bool = True
metadata = {
"cloud_default_overrides": {
"url": {"value": "", "placeholder": "Enter SearXNG URL"},
},
}
inputs = [
MessageTextInput(

View File

@ -0,0 +1,9 @@
"""Tests for cloud-mode default overrides on mixed compatibility components."""
from lfx.components.tools.searxng import SearXNGToolComponent
def test_searxng_component_clears_localhost_default_in_cloud_mode():
assert SearXNGToolComponent.metadata["cloud_default_overrides"] == {
"url": {"value": "", "placeholder": "Enter SearXNG URL"},
}