fix: improve duplicate model validation UX in config dialog

- Add inline error display for duplicate model IDs
- Show red border on input when error exists
- Validate on blur with shake animation for edit errors
- Prevent saving empty model names
- Clear errors when user starts typing
- Simplify error styling (small red text, no heavy chips)
This commit is contained in:
dayuan.jiang
2025-12-22 22:27:49 +09:00
parent 33fd2a16e6
commit 6086c4177a

View File

@@ -162,6 +162,11 @@ export function ModelConfigDialog({
const [validatingModelIndex, setValidatingModelIndex] = useState< const [validatingModelIndex, setValidatingModelIndex] = useState<
number | null number | null
>(null) >(null)
const [duplicateError, setDuplicateError] = useState<string>("")
const [editError, setEditError] = useState<{
modelId: string
message: string
} | null>(null)
const { const {
config, config,
@@ -219,19 +224,6 @@ export function ModelConfigDialog({
(modelId) => !existingModelIds.includes(modelId), (modelId) => !existingModelIds.includes(modelId),
) )
// Detect duplicate models in current config
const modelIdCounts =
selectedProvider?.models.reduce(
(acc, m) => {
acc[m.modelId] = (acc[m.modelId] || 0) + 1
return acc
},
{} as Record<string, number>,
) || {}
const duplicateModelIds = Object.keys(modelIdCounts).filter(
(id) => modelIdCounts[id] > 1,
)
// Handle adding a new provider // Handle adding a new provider
const handleAddProvider = (providerType: ProviderName) => { const handleAddProvider = (providerType: ProviderName) => {
const newProvider = addProvider(providerType) const newProvider = addProvider(providerType)
@@ -261,14 +253,17 @@ export function ModelConfigDialog({
} }
// Handle adding a model to current provider // Handle adding a model to current provider
const handleAddModel = (modelId: string) => { // Returns true if model was added successfully, false otherwise
if (!selectedProviderId || !selectedProvider) return const handleAddModel = (modelId: string): boolean => {
if (!selectedProviderId || !selectedProvider) return false
// Prevent duplicate model IDs // Prevent duplicate model IDs
if (existingModelIds.includes(modelId)) { if (existingModelIds.includes(modelId)) {
toast.warning(`Model "${modelId}" already exists in this provider`) setDuplicateError(`Model "${modelId}" already exists`)
return return false
} }
setDuplicateError("")
addModel(selectedProviderId, modelId) addModel(selectedProviderId, modelId)
return true
} }
// Handle deleting a model // Handle deleting a model
@@ -997,30 +992,58 @@ export function ModelConfigDialog({
<span>Models</span> <span>Models</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative">
<Input <Input
placeholder="Custom model ID..." placeholder="Custom model ID..."
value={customModelInput} value={
onChange={(e) => customModelInput
}
onChange={(e) => {
setCustomModelInput( setCustomModelInput(
e.target.value, e.target
.value,
)
// Clear duplicate error when typing
if (
duplicateError
) {
setDuplicateError(
"",
) )
} }
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if ( if (
e.key === e.key ===
"Enter" && "Enter" &&
customModelInput.trim() customModelInput.trim()
) { ) {
const success =
handleAddModel( handleAddModel(
customModelInput.trim(), customModelInput.trim(),
) )
if (
success
) {
setCustomModelInput( setCustomModelInput(
"", "",
) )
} }
}
}} }}
className="h-8 w-48 font-mono text-xs" className={cn(
"h-8 w-48 font-mono text-xs",
duplicateError &&
"border-destructive focus-visible:ring-destructive",
)}
/> />
{/* Show duplicate error for custom model input */}
{duplicateError && (
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
{duplicateError}
</p>
)}
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -1029,13 +1052,16 @@ export function ModelConfigDialog({
if ( if (
customModelInput.trim() customModelInput.trim()
) { ) {
const success =
handleAddModel( handleAddModel(
customModelInput.trim(), customModelInput.trim(),
) )
if (success) {
setCustomModelInput( setCustomModelInput(
"", "",
) )
} }
}
}} }}
disabled={ disabled={
!customModelInput.trim() !customModelInput.trim()
@@ -1089,26 +1115,6 @@ export function ModelConfigDialog({
</div> </div>
</div> </div>
{/* Duplicate Warning Banner */}
{duplicateModelIds.length > 0 && (
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>
{
duplicateModelIds.length
}{" "}
duplicate model
{duplicateModelIds.length >
1
? "s"
: ""}{" "}
detected. Remove
duplicates to avoid
confusion.
</span>
</div>
)}
{/* Model List */} {/* Model List */}
<div className="rounded-xl border bg-card overflow-hidden min-h-[120px]"> <div className="rounded-xl border bg-card overflow-hidden min-h-[120px]">
{selectedProvider.models {selectedProvider.models
@@ -1193,6 +1199,15 @@ export function ModelConfigDialog({
e, e,
) => { ) => {
// Allow free typing - validation happens on blur // Allow free typing - validation happens on blur
// Clear edit error when typing
if (
editError?.modelId ===
model.id
) {
setEditError(
null,
)
}
updateModel( updateModel(
selectedProviderId!, selectedProviderId!,
model.id, model.id,
@@ -1208,12 +1223,80 @@ export function ModelConfigDialog({
}, },
) )
}} }}
onKeyDown={(
e,
) => {
if (
e.key ===
"Enter"
) {
e.currentTarget.blur()
}
}}
onBlur={( onBlur={(
e, e,
) => { ) => {
const newModelId = const newModelId =
e.target.value.trim() e.target.value.trim()
// Check if new ID would be duplicate (excluding current model)
// Helper to show error with shake
const showError =
(
message: string,
) => {
setEditError(
{
modelId:
model.id,
message,
},
)
e.target.animate(
[
{
transform:
"translateX(0)",
},
{
transform:
"translateX(-4px)",
},
{
transform:
"translateX(4px)",
},
{
transform:
"translateX(-4px)",
},
{
transform:
"translateX(4px)",
},
{
transform:
"translateX(0)",
},
],
{
duration: 400,
easing: "ease-in-out",
},
)
e.target.focus()
}
// Check for empty model name
if (
!newModelId
) {
showError(
"Model ID cannot be empty",
)
return
}
// Check for duplicate
const otherModelIds = const otherModelIds =
selectedProvider?.models selectedProvider?.models
.filter( .filter(
@@ -1231,15 +1314,20 @@ export function ModelConfigDialog({
) || ) ||
[] []
if ( if (
newModelId &&
otherModelIds.includes( otherModelIds.includes(
newModelId, newModelId,
) )
) { ) {
toast.warning( showError(
`Model "${newModelId}" already exists. Please use a unique ID.`, "This model ID already exists",
) )
return
} }
// Clear error on valid blur
setEditError(
null,
)
}} }}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1" className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/> />
@@ -1261,34 +1349,20 @@ export function ModelConfigDialog({
{model.validated === {model.validated ===
false && false &&
model.validationError && ( model.validationError && (
<p className="text-xs text-destructive px-3 pb-2 pl-14"> <p className="text-[11px] text-destructive px-3 pb-2 pl-14">
{ {
model.validationError model.validationError
} }
</p> </p>
)} )}
{/* Show duplicate warning inline */} {/* Show edit error inline */}
{duplicateModelIds.includes( {editError?.modelId ===
model.modelId, model.id && (
) && ( <p className="text-[11px] text-destructive px-3 pb-2 pl-14">
<div className="flex items-center gap-2 px-3 pb-2 pl-14"> {
<span className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1"> editError.message
<AlertCircle className="h-3 w-3" />
Duplicate
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
handleDeleteModel(
model.id,
)
} }
> </p>
Remove
</Button>
</div>
)} )}
</div> </div>
), ),