diff --git a/web/src/components/ui/input-select.tsx b/web/src/components/ui/input-select.tsx
new file mode 100644
index 000000000..9c7099944
--- /dev/null
+++ b/web/src/components/ui/input-select.tsx
@@ -0,0 +1,303 @@
+import { Input } from '@/components/ui/input';
+import { cn } from '@/lib/utils';
+import { X } from 'lucide-react';
+import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Popover, PopoverContent, PopoverTrigger } from './popover';
+
+/** Interface for tag select options */
+export interface InputSelectOption {
+ /** Value of the option */
+ value: string;
+ /** Display label of the option */
+ label: string;
+}
+
+/** Properties for the InputSelect component */
+export interface InputSelectProps {
+ /** Options for the select component */
+ options?: InputSelectOption[];
+ /** Selected values - string for single select, array for multi select */
+ value?: string | string[];
+ /** Callback when value changes */
+ onChange?: (value: string | string[]) => void;
+ /** Placeholder text */
+ placeholder?: string;
+ /** Additional class names */
+ className?: string;
+ /** Style object */
+ style?: React.CSSProperties;
+ /** Whether to allow multiple selections */
+ multi?: boolean;
+}
+
+const InputSelect = React.forwardRef(
+ (
+ {
+ options = [],
+ value = [],
+ onChange,
+ placeholder = 'Select tags...',
+ className,
+ style,
+ multi = false,
+ },
+ ref,
+ ) => {
+ const [inputValue, setInputValue] = React.useState('');
+ const [open, setOpen] = React.useState(false);
+ const [isFocused, setIsFocused] = React.useState(false);
+ const inputRef = React.useRef(null);
+ const { t } = useTranslation();
+
+ // Normalize value to array for consistent handling
+ const normalizedValue = Array.isArray(value) ? value : value ? [value] : [];
+
+ /**
+ * Removes a tag from the selected values
+ * @param tagValue - The value of the tag to remove
+ */
+ const handleRemoveTag = (tagValue: string) => {
+ const newValue = normalizedValue.filter((v) => v !== tagValue);
+ // Return single value if not multi-select, otherwise return array
+ onChange?.(multi ? newValue : newValue[0] || '');
+ };
+
+ /**
+ * Adds a tag to the selected values
+ * @param optionValue - The value of the tag to add
+ */
+ const handleAddTag = (optionValue: string) => {
+ let newValue: string[];
+
+ if (multi) {
+ // For multi-select, add to array if not already included
+ if (!normalizedValue.includes(optionValue)) {
+ newValue = [...normalizedValue, optionValue];
+ onChange?.(newValue);
+ }
+ } else {
+ // For single-select, replace the value
+ newValue = [optionValue];
+ onChange?.(optionValue);
+ }
+
+ setInputValue('');
+ setOpen(false); // Close the popover after adding a tag
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ setInputValue(newValue);
+ setOpen(newValue.length > 0); // Open popover when there's input
+
+ // If input matches an option exactly, add it
+ const matchedOption = options.find(
+ (opt) => opt.label.toLowerCase() === newValue.toLowerCase(),
+ );
+
+ if (matchedOption && !normalizedValue.includes(matchedOption.value)) {
+ handleAddTag(matchedOption.value);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (
+ e.key === 'Backspace' &&
+ inputValue === '' &&
+ normalizedValue.length > 0
+ ) {
+ // Remove last tag when pressing backspace on empty input
+ const newValue = [...normalizedValue];
+ newValue.pop();
+ // Return single value if not multi-select, otherwise return array
+ onChange?.(multi ? newValue : newValue[0] || '');
+ } else if (e.key === 'Enter' && inputValue.trim() !== '') {
+ e.preventDefault();
+ // Add input value as a new tag if it doesn't exist in options
+ const matchedOption = options.find(
+ (opt) => opt.label.toLowerCase() === inputValue.toLowerCase(),
+ );
+
+ if (matchedOption) {
+ handleAddTag(matchedOption.value);
+ } else {
+ // If not in options, create a new tag with the input value
+ if (
+ !normalizedValue.includes(inputValue) &&
+ inputValue.trim() !== ''
+ ) {
+ handleAddTag(inputValue);
+ }
+ }
+ } else if (e.key === 'Escape') {
+ inputRef.current?.blur();
+ setOpen(false);
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ // Allow navigation in the dropdown
+ return;
+ }
+ };
+
+ const handleContainerClick = () => {
+ inputRef.current?.focus();
+ setOpen(true);
+ setIsFocused(true);
+ };
+
+ const handleInputFocus = () => {
+ setOpen(true);
+ setIsFocused(true);
+ };
+
+ const handleInputBlur = () => {
+ // Delay closing to allow click on options
+ setTimeout(() => {
+ setOpen(false);
+ setIsFocused(false);
+ }, 150);
+ };
+
+ // Filter options to exclude already selected ones (only for multi-select)
+ const availableOptions = multi
+ ? options.filter((option) => !normalizedValue.includes(option.value))
+ : options;
+
+ const filteredOptions = availableOptions.filter(
+ (option) =>
+ !inputValue ||
+ option.label.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ // If there are no matching options but there is an input value, create a new option with the input value
+ const hasMatchingOptions = filteredOptions.length > 0;
+ const showInputAsOption =
+ inputValue &&
+ !hasMatchingOptions &&
+ !normalizedValue.includes(inputValue);
+
+ const triggerElement = (
+
+ {/* Render selected tags - only show tags if multi is true or if single select has a value */}
+ {multi &&
+ normalizedValue.map((tagValue) => {
+ const option = options.find((opt) => opt.value === tagValue) || {
+ value: tagValue,
+ label: tagValue,
+ };
+ return (
+
+ {option.label}
+
+
+ );
+ })}
+
+ {/* For single select, show the selected value as text instead of a tag */}
+ {!multi && normalizedValue[0] && (
+
+ )}
+
+ {/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */}
+ {(multi ? isFocused : multi || !normalizedValue[0]) && (
+ e.stopPropagation()}
+ onFocus={handleInputFocus}
+ onBlur={handleInputBlur}
+ />
+ )}
+
+ );
+
+ return (
+
+ {triggerElement}
+ e.preventDefault()} // Prevent auto focus on content
+ >
+