mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
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:
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user