feat(ui): enhance model select modal UX and modal traffic lights (#1111)

* feat(model-select-modal): highlight added models and support bulk selection

- Add addedModelValues prop to highlight already-added models with primary color
- Sort models alphabetically per provider, with added models floated to top
- Replace green highlight with primary brand color (orange #E56A4A)
- Use check icon (10px) inline with model name instead of check_circle
- Replace Done button with info bar explaining click-to-toggle behavior
- Add ProviderIcon to provider group headers replacing colored dot
- Import ProviderIcon, remove unused Button import

* feat(cli-tools): wire addedModelValues, onDeselect, and auto-save to model select modals

- Pass selectedModels as addedModelValues to ModelSelectModal in OpenCode and Copilot cards
- Add onDeselect handler to remove model from list on second click
- Set closeOnSelect=false to allow bulk model selection
- Remove manual setModalOpen(false) from onSelect callbacks
- Add saveModels() silent auto-save triggered on modal close (OpenCodeToolCard)
- Use useRef to track latest selectedModels in closure-safe way

* feat(modal): functional traffic light close button with hover icon and tooltip

- Make red dot a clickable button that closes the modal
- Show ✕ icon inside red dot on hover via group-hover opacity transition
- Gray out yellow and green dots (cursor-not-allowed, no tooltip)
- Increase dot size from w-3 h-3 to w-4 h-4
- Add Tooltip with brand-matched color #FF5F56 on red dot
- Remove X close button from modal header

* feat(tooltip): add color prop for themed tooltip backgrounds

* feat(i18n): add translations for model select info bar and close tooltip

- Add 'Click to add, click again to remove. Changes are saved automatically.' to all 32 locales
- Add 'Close' translation to all 32 locales

* fix(ui): address code review feedback on modal UX and auto-save

- Modal: remove showCloseButton prop, use showTrafficLights for header
  condition, hide traffic lights on mobile (hidden md:flex), add mobile
  X button (md:hidden) with aria-label, add aria-label and title on
  traffic light close button
- OpenCodeToolCard: validate activeModel membership before saving —
  fallback to models[0] or empty string; clear/reassign activeModel
  on deselect when removed model was the active one
- CopilotToolCard: add useRef + selectedModelsRef, add saveModels()
  using /api/cli-tools/copilot-settings, wire auto-save on modal close
- ModelSelectModal: fix JSX formatting — separate info bar closing div
  from Search comment onto its own line
This commit is contained in:
Rigel Ramadhani Waloni
2026-05-15 09:21:24 +07:00
committed by GitHub
parent 4098f91ac5
commit 1fd3132647
37 changed files with 209 additions and 276 deletions

View File

@@ -3,7 +3,7 @@
import { useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "./Modal";
import Button from "./Button";
import ProviderIcon from "./ProviderIcon";
import { getModelsByProviderId } from "@/shared/constants/models";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers";
@@ -318,32 +318,37 @@ export default function ModelSelectModal({
return combos.filter(c => c.name.toLowerCase().includes(query));
}, [combos, searchQuery, kindFilter]);
// Sort models alphabetically, with added models floated to top
const sortModels = (models) => {
const added = models.filter(m => addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name));
const rest = models.filter(m => !addedModelValues.includes(m.value)).sort((a, b) => a.name.localeCompare(b.name));
return [...added, ...rest];
};
// Filter models by search query
const filteredGroups = useMemo(() => {
if (!searchQuery.trim()) return groupedModels;
const query = searchQuery.trim().toLowerCase();
const query = searchQuery.toLowerCase();
const filtered = {};
Object.entries(groupedModels).forEach(([providerId, group]) => {
const matchedModels = group.models.filter(
(m) =>
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
);
const providerNameMatches = group.name.toLowerCase().includes(query);
if (matchedModels.length > 0 || providerNameMatches) {
filtered[providerId] = {
...group,
models: matchedModels,
};
let models = group.models;
if (query) {
const providerNameMatches = group.name.toLowerCase().includes(query);
models = models.filter(
(m) =>
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
);
if (models.length === 0 && !providerNameMatches) return;
}
filtered[providerId] = {
...group,
models: sortModels(models),
};
});
return filtered;
}, [groupedModels, searchQuery]);
}, [groupedModels, searchQuery, addedModelValues]);
const handleSelect = (model) => {
const value = model?.value || model?.name || model;
@@ -371,20 +376,14 @@ export default function ModelSelectModal({
title={title}
size="md"
className="p-4!"
footer={
!closeOnSelect ? (
<Button
onClick={() => {
onClose();
setSearchQuery("");
}}
fullWidth
>
Done
</Button>
) : null
}
footer={null}
>
{/* Info bar */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-2 bg-primary/8 border border-primary/20 rounded-lg text-xs text-text-muted">
<span className="material-symbols-outlined text-primary shrink-0" style={{ fontSize: "14px" }}>info</span>
<span>Click to add, click again to remove. Changes are saved automatically.</span>
</div>
{/* Search - compact */}
<div className="mb-3">
<div className="relative">
@@ -423,13 +422,13 @@ export default function ModelSelectModal({
${isSelected
? "bg-primary text-white border-primary"
: addedModelValues.includes(combo.name)
? "bg-green-500/10 border-green-500/30 text-green-700 dark:text-green-400 hover:border-green-500/50"
? "bg-primary border-primary text-white hover:bg-primary-hover"
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
}
`}
>
{addedModelValues.includes(combo.name) && (
<span className="material-symbols-outlined text-[12px]">check_circle</span>
<span className="material-symbols-outlined leading-none" style={{ fontSize: "10px" }}>check</span>
)}
{combo.name}
</button>
@@ -444,9 +443,12 @@ export default function ModelSelectModal({
<div key={providerId}>
{/* Provider header */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: group.color }}
<ProviderIcon
src={`/providers/${providerId}.png`}
alt={group.name}
size={14}
fallbackText={(group.name || providerId).slice(0, 2).toUpperCase()}
fallbackColor={group.color}
/>
<span className="text-xs font-medium text-primary">
{group.name}
@@ -472,14 +474,14 @@ export default function ModelSelectModal({
: isSelected
? "bg-primary text-white border-primary"
: addedModelValues.includes(model.value)
? "bg-green-500/10 border-green-500/30 text-green-700 dark:text-green-400 hover:border-green-500/50"
? "bg-primary border-primary text-white hover:bg-primary-hover"
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
}
`}
>
<span className="flex items-center gap-1">
{addedModelValues.includes(model.value) && !isPlaceholder && (
<span className="material-symbols-outlined text-[12px]">check_circle</span>
<span className="material-symbols-outlined leading-none" style={{ fontSize: "10px" }}>check</span>
)}
{isPlaceholder ? (
<>