fix(FN-2419): preserve three-state bulk model selection

- Add optional noChangeValue/noChangeLabel support to CustomModelDropdown so No change can coexist with Use default
- Update ListView bulk model controls to pass through a dedicated __no_change__ sentinel and avoid conflating untouched with clear-to-default
- Keep batch update payloads explicit by sending null provider/model only for Use default while leaving untouched lanes undefined
- Add dashboard tests covering No change vs explicit model vs Use default behavior and update README bulk-edit documentation
This commit is contained in:
Fusion
2026-04-24 04:50:26 -07:00
committed by gsxdsm
parent 8ffe005ba1
commit 0ebe20e487
5 changed files with 149 additions and 35 deletions

View File

@@ -12,6 +12,10 @@ export interface CustomModelDropdownProps {
disabled?: boolean;
id?: string;
label: string;
/** Optional untouched sentinel value for contexts that need a third lane (e.g. list-view bulk edit). */
noChangeValue?: string;
/** Display label for noChangeValue (defaults to "No change"). */
noChangeLabel?: string;
/** List of favorite provider names in preferred order */
favoriteProviders?: string[];
/** Called when user toggles a provider's favorite status */
@@ -54,6 +58,8 @@ export function CustomModelDropdown({
onToggleFavorite,
favoriteModels = [],
onToggleModelFavorite,
noChangeValue,
noChangeLabel = "No change",
}: CustomModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
@@ -118,19 +124,29 @@ export function CustomModelDropdown({
});
}, [modelsByProvider, favoriteProviders]);
const hasNoChangeOption = typeof noChangeValue === "string" && noChangeValue.length > 0;
// Get current provider from value
const currentProvider = useMemo(() => {
if (!value) return null;
if (!value || (hasNoChangeOption && value === noChangeValue)) return null;
const slashIdx = value.indexOf("/");
return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [value]);
}, [hasNoChangeOption, noChangeValue, value]);
const specialOptions = useMemo(() => {
const options: Array<{ type: "default" | "no-change"; value: string; label: string }> = [];
if (hasNoChangeOption) {
options.push({ type: "no-change", value: noChangeValue, label: noChangeLabel });
}
options.push({ type: "default", value: "", label: "Use default" });
return options;
}, [hasNoChangeOption, noChangeLabel, noChangeValue]);
// Build list of all selectable options (for keyboard navigation)
// Includes favorited models first (as pinned rows), then provider groups
// Includes special rows first (optional "No change" + "Use default"),
// favorited models next, then provider groups.
const optionsList = useMemo(() => {
const options: Array<{ type: "default" | "provider" | "model" | "favorite"; value: string; label: string; provider?: string }> = [
{ type: "default", value: "", label: "Use default" },
];
const options: Array<{ type: "default" | "no-change" | "provider" | "model" | "favorite"; value: string; label: string; provider?: string }> = [...specialOptions];
// Add favorited models as pinned rows first
for (const { model, fullId } of favoritedModelEntries) {
@@ -155,10 +171,13 @@ export function CustomModelDropdown({
});
return options;
}, [favoritedModelEntries, sortedProviderEntries]);
}, [favoritedModelEntries, sortedProviderEntries, specialOptions]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
if (hasNoChangeOption && value === noChangeValue) {
return noChangeLabel;
}
if (!value) return "Use default";
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
@@ -166,7 +185,7 @@ export function CustomModelDropdown({
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
}, [value, models]);
}, [hasNoChangeOption, noChangeLabel, noChangeValue, value, models]);
// Find index of current value in options list
const currentValueIndex = useMemo(() => {
@@ -470,23 +489,26 @@ export function CustomModelDropdown({
</div>
<div ref={listRef} className="model-combobox-list">
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{specialOptions.map((option, index) => (
<div
key={`${option.type}-${option.value}`}
data-index={index}
className={`model-combobox-option ${highlightedIndex === index ? "model-combobox-option--highlighted" : ""} ${value === option.value ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setHighlightedIndex(index)}
role="option"
aria-selected={value === option.value}
>
<span className="model-combobox-option-text model-combobox-option-text--default">{option.label}</span>
</div>
))}
{/* Favorited models as pinned rows */}
{favoritedModelEntries.length > 0 && (
<>
<div className="model-combobox-divider" />
{favoritedModelEntries.map(({ model, fullId }, idx) => {
const optionIndex = idx + 1; // +1 for "Use default" at index 0
const optionIndex = idx + specialOptions.length;
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === fullId;
return (