From 637f4e50f25a08a2fa1a167eef7ba7a5bb2c2d9b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:08:16 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20model=20editin?= =?UTF-8?q?g=20capabilities=20to=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add inline editing for custom models with pencil icon button - Prevent duplicate model additions with error messages - Support keyboard shortcuts: Enter to save, Escape to cancel - Disable edit/delete buttons while another model is being edited Deletion of models used by workspaces is safe by design: model selections are stored as strings and passed to providers, so removing a model from settings only removes the shortcut. _Generated with `mux`_ --- .../Settings/sections/ModelsSection.tsx | 190 +++++++++++++++--- 1 file changed, 165 insertions(+), 25 deletions(-) diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index c2056142c..a69a030d5 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2, Pencil, Check, X } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; @@ -8,10 +8,18 @@ interface NewModelForm { modelId: string; } +interface EditingState { + provider: string; + originalModelId: string; + newModelId: string; +} + export function ModelsSection() { const [config, setConfig] = useState({}); const [newModel, setNewModel] = useState({ provider: "", modelId: "" }); const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); // Load config on mount useEffect(() => { @@ -34,13 +42,31 @@ export function ModelsSection() { return models; }; + // Check if a model already exists (for duplicate prevention) + const modelExists = useCallback( + (provider: string, modelId: string, excludeOriginal?: string): boolean => { + const currentModels = config[provider]?.models ?? []; + return currentModels.some((m) => m === modelId && m !== excludeOriginal); + }, + [config] + ); + const handleAddModel = useCallback(async () => { if (!newModel.provider || !newModel.modelId.trim()) return; + const trimmedModelId = newModel.modelId.trim(); + + // Check for duplicates + if (modelExists(newModel.provider, trimmedModelId)) { + setError(`Model "${trimmedModelId}" already exists for this provider`); + return; + } + + setError(null); setSaving(true); try { const currentModels = config[newModel.provider]?.models ?? []; - const updatedModels = [...currentModels, newModel.modelId.trim()]; + const updatedModels = [...currentModels, trimmedModelId]; await window.api.providers.setModels(newModel.provider, updatedModels); @@ -54,7 +80,7 @@ export function ModelsSection() { } finally { setSaving(false); } - }, [newModel, config]); + }, [newModel, config, modelExists]); const handleRemoveModel = useCallback( async (provider: string, modelId: string) => { @@ -78,6 +104,55 @@ export function ModelsSection() { [config] ); + const handleStartEdit = useCallback((provider: string, modelId: string) => { + setEditing({ provider, originalModelId: modelId, newModelId: modelId }); + setError(null); + }, []); + + const handleCancelEdit = useCallback(() => { + setEditing(null); + setError(null); + }, []); + + const handleSaveEdit = useCallback(async () => { + if (!editing) return; + + const trimmedModelId = editing.newModelId.trim(); + if (!trimmedModelId) { + setError("Model ID cannot be empty"); + return; + } + + // Only validate duplicates if the model ID actually changed + if (trimmedModelId !== editing.originalModelId) { + if (modelExists(editing.provider, trimmedModelId)) { + setError(`Model "${trimmedModelId}" already exists for this provider`); + return; + } + } + + setError(null); + setSaving(true); + try { + const currentModels = config[editing.provider]?.models ?? []; + const updatedModels = currentModels.map((m) => + m === editing.originalModelId ? trimmedModelId : m + ); + + await window.api.providers.setModels(editing.provider, updatedModels); + + // Refresh config + const cfg = await window.api.providers.getConfig(); + setConfig(cfg); + setEditing(null); + + // Notify other components about the change + window.dispatchEvent(new Event("providers-config-changed")); + } finally { + setSaving(false); + } + }, [editing, config, modelExists]); + const allModels = getAllModels(); return ( @@ -122,6 +197,7 @@ export function ModelsSection() { Add + {error && !editing &&
{error}
} {/* List of custom models */} @@ -130,29 +206,93 @@ export function ModelsSection() {
Custom Models
- {allModels.map(({ provider, modelId }) => ( -
-
- - {PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ?? - provider} - - {modelId} -
- -
- ))} +
+ + {PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ?? + provider} + + {isEditing ? ( +
+ + setEditing((prev) => + prev ? { ...prev, newModelId: e.target.value } : null + ) + } + onKeyDown={(e) => { + if (e.key === "Enter") void handleSaveEdit(); + if (e.key === "Escape") handleCancelEdit(); + }} + className="bg-modal-bg border-border-medium focus:border-accent min-w-0 flex-1 rounded border px-2 py-1 font-mono text-xs focus:outline-none" + autoFocus + /> + {error &&
{error}
} +
+ ) : ( + + {modelId} + + )} +
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + ); + })} ) : (
From fd91d9fe630fd57a7eb3f51c7288d802325a0438 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:12:25 +0000 Subject: [PATCH 2/2] feat: show loading spinner while fetching settings --- .../Settings/sections/ModelsSection.tsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index a69a030d5..3c634e772 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Plus, Trash2, Pencil, Check, X } from "lucide-react"; +import { Plus, Trash2, Pencil, Check, X, Loader2 } from "lucide-react"; import type { ProvidersConfigMap } from "../types"; import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; @@ -15,7 +15,7 @@ interface EditingState { } export function ModelsSection() { - const [config, setConfig] = useState({}); + const [config, setConfig] = useState(null); const [newModel, setNewModel] = useState({ provider: "", modelId: "" }); const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); @@ -29,22 +29,10 @@ export function ModelsSection() { })(); }, []); - // Get all custom models across providers - const getAllModels = (): Array<{ provider: string; modelId: string }> => { - const models: Array<{ provider: string; modelId: string }> = []; - for (const [provider, providerConfig] of Object.entries(config)) { - if (providerConfig.models) { - for (const modelId of providerConfig.models) { - models.push({ provider, modelId }); - } - } - } - return models; - }; - // Check if a model already exists (for duplicate prevention) const modelExists = useCallback( (provider: string, modelId: string, excludeOriginal?: string): boolean => { + if (!config) return false; const currentModels = config[provider]?.models ?? []; return currentModels.some((m) => m === modelId && m !== excludeOriginal); }, @@ -52,7 +40,7 @@ export function ModelsSection() { ); const handleAddModel = useCallback(async () => { - if (!newModel.provider || !newModel.modelId.trim()) return; + if (!config || !newModel.provider || !newModel.modelId.trim()) return; const trimmedModelId = newModel.modelId.trim(); @@ -84,6 +72,7 @@ export function ModelsSection() { const handleRemoveModel = useCallback( async (provider: string, modelId: string) => { + if (!config) return; setSaving(true); try { const currentModels = config[provider]?.models ?? []; @@ -115,7 +104,7 @@ export function ModelsSection() { }, []); const handleSaveEdit = useCallback(async () => { - if (!editing) return; + if (!config || !editing) return; const trimmedModelId = editing.newModelId.trim(); if (!trimmedModelId) { @@ -153,6 +142,29 @@ export function ModelsSection() { } }, [editing, config, modelExists]); + // Show loading state while config is being fetched + if (config === null) { + return ( +
+ + Loading settings... +
+ ); + } + + // Get all custom models across providers + const getAllModels = (): Array<{ provider: string; modelId: string }> => { + const models: Array<{ provider: string; modelId: string }> = []; + for (const [provider, providerConfig] of Object.entries(config)) { + if (providerConfig.models) { + for (const modelId of providerConfig.models) { + models.push({ provider, modelId }); + } + } + } + return models; + }; + const allModels = getAllModels(); return (