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
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
@@ -19,6 +19,11 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [selectedModels, setSelectedModels] = useState([]);
const [modalOpen, setModalOpen] = useState(false);
const selectedModelsRef = useRef([]);
useEffect(() => {
selectedModelsRef.current = selectedModels;
}, [selectedModels]);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@@ -58,6 +63,21 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
}
};
const saveModels = async (models) => {
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
await fetch("/api/cli-tools/copilot-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models }),
});
} catch (error) {
console.log("Error saving models:", error);
}
};
const getConfigStatus = () => {
if (!status) return null;
if (!status.has9Router) return "not_configured";
@@ -272,16 +292,23 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
}
setModalOpen(false);
}}
onDeselect={(model) => {
setSelectedModels(selectedModels.filter(m => m !== model.value));
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for GitHub Copilot"
/>
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
@@ -24,6 +24,11 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [activeModel, setActiveModel] = useState("");
const selectedModelsRef = useRef([]);
useEffect(() => {
selectedModelsRef.current = selectedModels;
}, [selectedModels]);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@@ -68,6 +73,28 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
}
};
const saveModels = async (models) => {
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const validActiveModel = models.includes(activeModel) ? activeModel : (models[0] || "");
await fetch("/api/cli-tools/opencode-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
models,
activeModel: validActiveModel,
subagentModel,
}),
});
} catch (error) {
console.log("Error saving models:", error);
}
};
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.config) return "not_configured";
@@ -427,17 +454,28 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onClose={() => {
setModalOpen(false);
saveModels(selectedModelsRef.current);
}}
onSelect={(model) => {
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
if (!activeModel) setActiveModel(model.value);
}
setModalOpen(false);
}}
onDeselect={(model) => {
const remaining = selectedModels.filter(m => m !== model.value);
setSelectedModels(remaining);
if (activeModel === model.value) {
setActiveModel(remaining[0] || "");
}
}}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
addedModelValues={selectedModels}
closeOnSelect={false}
title="Add Model for OpenCode"
/>
+24 -14
View File
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { cn } from "@/shared/utils/cn";
import Button from "./Button";
import Tooltip from "./Tooltip";
export default function Modal({
isOpen,
@@ -12,7 +13,6 @@ export default function Modal({
footer,
size = "md",
closeOnOverlay = true,
showCloseButton = true,
showTrafficLights = true,
className,
}) {
@@ -63,28 +63,38 @@ export default function Modal({
)}
>
{/* Header */}
{(title || showCloseButton) && (
{(title || showTrafficLights) && (
<div className="flex items-center justify-between p-2 border-b border-border-subtle">
<div className="flex items-center">
{/* Traffic lights — desktop only */}
{showTrafficLights && (
<div className="flex items-center gap-2 mr-4 ml-2">
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
<div className="hidden md:flex items-center gap-2 mr-4 ml-2">
<Tooltip text="Close" position="top" color="#FF5F56">
<button
onClick={onClose}
aria-label="Close"
title="Close"
className="w-4 h-4 rounded-full bg-[#FF5F56] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center group/dot"
>
<span className="text-[9px] font-bold text-white opacity-0 group-hover/dot:opacity-100 transition-opacity leading-none"></span>
</button>
</Tooltip>
<div className="w-4 h-4 rounded-full bg-[#3a3a3a]/20 dark:bg-white/15 cursor-not-allowed" />
<div className="w-4 h-4 rounded-full bg-[#3a3a3a]/20 dark:bg-white/15 cursor-not-allowed" />
</div>
)}
{title && (
<h2 className="text-lg font-semibold text-text-main">{title}</h2>
)}
</div>
{showCloseButton && (
<button
onClick={onClose}
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
)}
{/* X button — mobile only */}
<button
onClick={onClose}
aria-label="Close"
className="md:hidden p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
)}
+40 -38
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 ? (
<>
+8 -2
View File
@@ -1,6 +1,6 @@
"use client";
export default function Tooltip({ text, children, position = "top" }) {
export default function Tooltip({ text, children, position = "top", color }) {
const posClass = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5",
bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5",
@@ -8,10 +8,16 @@ export default function Tooltip({ text, children, position = "top" }) {
right: "left-full top-1/2 -translate-y-1/2 ml-1.5",
}[position];
const bgStyle = color ? { backgroundColor: color } : {};
const bgClass = color ? "" : "bg-gray-900";
return (
<div className="relative inline-flex group">
{children}
<div className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug bg-gray-900 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-normal`}>
<div
className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug ${bgClass} text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-normal`}
style={bgStyle}
>
{text}
</div>
</div>