From fcfb0f47f9918f7fd37973b2a28f3203ec8c26f5 Mon Sep 17 00:00:00 2001 From: Dustin Healy Date: Mon, 7 Jul 2025 10:22:35 -0700 Subject: [PATCH] WIP: Responsive Segmented Controls --- .../SidePanel/Parameters/DynamicSegment.tsx | 130 ++++++++++++++ .../SidePanel/Parameters/components.tsx | 2 + .../components/SidePanel/Parameters/index.ts | 1 + client/src/components/ui/SegmentedControl.tsx | 169 ++++++++++++++++++ client/src/components/ui/index.ts | 1 + packages/data-provider/src/generate.ts | 19 +- .../data-provider/src/parameterSettings.ts | 8 +- 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 client/src/components/SidePanel/Parameters/DynamicSegment.tsx create mode 100644 client/src/components/ui/SegmentedControl.tsx diff --git a/client/src/components/SidePanel/Parameters/DynamicSegment.tsx b/client/src/components/SidePanel/Parameters/DynamicSegment.tsx new file mode 100644 index 0000000000..9040ec1deb --- /dev/null +++ b/client/src/components/SidePanel/Parameters/DynamicSegment.tsx @@ -0,0 +1,130 @@ +import { useMemo, useState } from 'react'; +import { OptionTypes } from 'librechat-data-provider'; +import type { DynamicSettingProps } from 'librechat-data-provider'; +import { Label, HoverCard, HoverCardTrigger, SegmentedControl } from '~/components/ui'; +import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; +import { useChatContext } from '~/Providers'; +import OptionHover from './OptionHover'; +import { ESide } from '~/common'; +import { cn } from '~/utils'; + +function DynamicSegment({ + label = '', + settingKey, + defaultValue, + description = '', + columnSpan, + setOption, + optionType, + options, + enumMappings, + readonly = false, + showLabel = true, + showDefault = false, + labelCode = false, + descriptionCode = false, + conversation, +}: DynamicSettingProps) { + const localize = useLocalize(); + const { preset } = useChatContext(); + const [inputValue, setInputValue] = useState(null); + + const selectedValue = useMemo(() => { + if (optionType === OptionTypes.Custom) { + // TODO: custom logic, add to payload but not to conversation + return inputValue; + } + + return conversation?.[settingKey] ?? defaultValue; + }, [conversation, defaultValue, optionType, settingKey, inputValue]); + + const handleChange = (value: string) => { + if (optionType === OptionTypes.Custom) { + // TODO: custom logic, add to payload but not to conversation + setInputValue(value); + return; + } + setOption(settingKey)(value); + }; + + useParameterEffects({ + preset, + settingKey, + defaultValue, + conversation, + inputValue, + setInputValue, + preventDelayedUpdate: true, + }); + + if (!options || options.length === 0) { + return null; + } + + // Convert options to SegmentedControl format with proper localization + const segmentOptions = + options?.map((option) => { + const optionValue = typeof option === 'string' ? option : String(option); + const optionLabel = typeof option === 'string' ? option : String(option); + + // Use enum mappings for localization if available + const localizedLabel = enumMappings?.[optionValue] + ? localize(enumMappings[optionValue] as TranslationKeys) || + String(enumMappings[optionValue]) + : optionLabel; + + return { + label: String(localizedLabel), + value: optionValue, + disabled: false, + }; + }) || []; + + return ( +
+ + + {showLabel === true && ( +
+ +
+ )} + +
+ {description && ( + + )} +
+
+ ); +} + +export default DynamicSegment; diff --git a/client/src/components/SidePanel/Parameters/components.tsx b/client/src/components/SidePanel/Parameters/components.tsx index 9c36d0eb20..abe3df15ed 100644 --- a/client/src/components/SidePanel/Parameters/components.tsx +++ b/client/src/components/SidePanel/Parameters/components.tsx @@ -9,6 +9,7 @@ import { DynamicSwitch, DynamicInput, DynamicTags, + DynamicSegment, } from './'; export const componentMapping: Record< @@ -23,4 +24,5 @@ export const componentMapping: Record< [ComponentTypes.Checkbox]: DynamicCheckbox, [ComponentTypes.Tags]: DynamicTags, [ComponentTypes.Combobox]: DynamicCombobox, + [ComponentTypes.Segment]: DynamicSegment, }; diff --git a/client/src/components/SidePanel/Parameters/index.ts b/client/src/components/SidePanel/Parameters/index.ts index 159a9f3787..1ef891f2d3 100644 --- a/client/src/components/SidePanel/Parameters/index.ts +++ b/client/src/components/SidePanel/Parameters/index.ts @@ -6,4 +6,5 @@ export { default as DynamicSlider } from './DynamicSlider'; export { default as DynamicSwitch } from './DynamicSwitch'; export { default as DynamicInput } from './DynamicInput'; export { default as DynamicTags } from './DynamicTags'; +export { default as DynamicSegment } from './DynamicSegment'; export { default as OptionHoverAlt } from './OptionHover'; diff --git a/client/src/components/ui/SegmentedControl.tsx b/client/src/components/ui/SegmentedControl.tsx new file mode 100644 index 0000000000..502e272484 --- /dev/null +++ b/client/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,169 @@ +import { forwardRef, useEffect, useRef, useState } from 'react'; +import { cn } from '~/utils'; + +export interface SegmentedControlOption { + label: string; + value: string; + disabled?: boolean; +} + +export interface SegmentedControlProps { + options: SegmentedControlOption[]; + value?: string; + onValueChange?: (value: string) => void; + name?: string; + className?: string; + disabled?: boolean; +} + +export const SegmentedControl = forwardRef( + ({ options, value, onValueChange, name, className, disabled }, ref) => { + const containerRef = useRef(null); + const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, height: 0, left: 0, top: 0 }); + const [isInitialized, setIsInitialized] = useState(false); + const [useGrid, setUseGrid] = useState(false); + + // Ensure we always have a current value + const currentValue = value !== undefined ? value : options[0]?.value; + + const handleChange = (newValue: string) => { + if (disabled) return; + onValueChange?.(newValue); + }; + + const updateIndicator = () => { + if (!containerRef.current) return; + + const selector = currentValue === '' ? '[data-value=""]' : `[data-value="${currentValue}"]`; + const activeButton = containerRef.current.querySelector(selector) as HTMLButtonElement; + + if (activeButton) { + const containerRect = containerRef.current.getBoundingClientRect(); + const buttonRect = activeButton.getBoundingClientRect(); + + if (useGrid) { + // 2x2 grid layout - use full button dimensions + setIndicatorStyle({ + width: buttonRect.width, + height: buttonRect.height, + left: buttonRect.left - containerRect.left, + top: buttonRect.top - containerRect.top, + }); + } else { + // 1-row layout - account for flex-1 distribution + const containerPadding = 4; // p-1 = 4px + setIndicatorStyle({ + width: buttonRect.width, + height: buttonRect.height, + left: buttonRect.left - containerRect.left - containerPadding, + top: buttonRect.top - containerRect.top - containerPadding, + }); + } + + if (!isInitialized) { + setIsInitialized(true); + } + } + }; + + // Check if text is being truncated and switch to grid if needed + const checkLayout = () => { + if (!containerRef.current) return; + + const buttons = containerRef.current.querySelectorAll('button'); + let needsGrid = false; + + buttons.forEach((button) => { + if (button.scrollWidth > button.clientWidth) { + needsGrid = true; + } + }); + + if (needsGrid !== useGrid) { + setUseGrid(needsGrid); + } + }; + + // Initialize and handle resize + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + checkLayout(); + updateIndicator(); + }); + }); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + // Initial check + setTimeout(() => { + checkLayout(); + updateIndicator(); + }, 0); + } + + return () => resizeObserver.disconnect(); + }, []); + + // Update indicator when value changes + useEffect(() => { + updateIndicator(); + }, [currentValue, options]); + + return ( +
+ {/* Sliding background indicator */} +
+ + {options.map((option) => { + const isActive = currentValue === option.value; + const isDisabled = disabled || option.disabled; + + return ( + + ); + })} +
+ ); + }, +); + +SegmentedControl.displayName = 'SegmentedControl'; diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 4f989484de..06950c081f 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -46,3 +46,4 @@ export { default as InputWithDropdown } from './InputWithDropDown'; export { default as SelectDropDownPop } from './SelectDropDownPop'; export { default as AnimatedSearchInput } from './AnimatedSearchInput'; export { default as MultiSelectDropDown } from './MultiSelectDropDown'; +export { SegmentedControl } from './SegmentedControl'; diff --git a/packages/data-provider/src/generate.ts b/packages/data-provider/src/generate.ts index 21f63a34d9..1ec81e5b68 100644 --- a/packages/data-provider/src/generate.ts +++ b/packages/data-provider/src/generate.ts @@ -14,7 +14,8 @@ export type ComponentType = | 'switch' | 'dropdown' | 'combobox' - | 'tags'; + | 'tags' + | 'segment'; export type OptionType = 'conversation' | 'model' | 'custom'; @@ -34,6 +35,7 @@ export enum ComponentTypes { Dropdown = 'dropdown', Combobox = 'combobox', Tags = 'tags', + Segment = 'segment', } export enum SettingTypes { @@ -206,6 +208,7 @@ const maxColumns = 4; const minSliderOptions = 2; const minDropdownOptions = 2; const minComboboxOptions = 2; +const minSegmentOptions = 2; /** * Validates the provided setting using the constraints unique to each component type. @@ -361,6 +364,20 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number } + if (setting.component === ComponentTypes.Segment) { + if ( + setting.type === SettingTypes.Enum && + (!setting.options || setting.options.length < minSegmentOptions) + ) { + errors.push({ + code: ZodIssueCode.custom, + message: `Segment component for setting ${setting.key} requires at least ${minSegmentOptions} options for enum type.`, + path: ['options'], + }); + // continue; + } + } + if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) { if (setting.default === undefined && setting.range) { // Set default to the middle of the range if unspecified diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index f01ad6139c..7c8bcfe77b 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -70,7 +70,7 @@ const baseDefinitions: Record = { descriptionCode: true, type: 'enum', default: ImageDetail.auto, - component: 'slider', + component: 'segment', options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high], enumMappings: { [ImageDetail.low]: 'com_ui_low', @@ -218,7 +218,7 @@ const openAIParams: Record = { descriptionCode: true, type: 'enum', default: ReasoningEffort.none, - component: 'slider', + component: 'segment', options: [ ReasoningEffort.none, ReasoningEffort.low, @@ -268,7 +268,7 @@ const openAIParams: Record = { descriptionCode: true, type: 'enum', default: ReasoningSummary.none, - component: 'slider', + component: 'segment', options: [ ReasoningSummary.none, ReasoningSummary.auto, @@ -276,7 +276,7 @@ const openAIParams: Record = { ReasoningSummary.detailed, ], enumMappings: { - [ReasoningSummary.none]: 'com_ui_none', + [ReasoningSummary.none]: 'com_ui_none_', [ReasoningSummary.auto]: 'com_ui_auto', [ReasoningSummary.concise]: 'com_ui_concise', [ReasoningSummary.detailed]: 'com_ui_detailed',