Skip to content

Commit 0085b92

Browse files
authored
🤖 feat: add model editing capabilities to settings (#798)
## Summary Adds inline editing for custom models in the Settings > Models section, with duplicate prevention and a loading state. ## Changes - **Model editing**: Click the pencil icon to edit a model ID inline - Enter to save, Escape to cancel - Check/X buttons for mouse users - **Duplicate prevention**: Shows error when adding or renaming to an existing model ID - **Loading state**: Shows spinner with "Loading settings..." while fetching config - **UX polish**: Edit/delete buttons disabled while another model is being edited ## Deletion safety Deleting a model from settings only removes it from the dropdown—it doesn't invalidate workspace selections. Model strings are stored in localStorage and passed directly to providers, so workspaces keep working until the user changes their selection. _Generated with `mux`_
1 parent a2a95af commit 0085b92

File tree

1 file changed

+191
-39
lines changed

1 file changed

+191
-39
lines changed

src/browser/components/Settings/sections/ModelsSection.tsx

Lines changed: 191 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useState, useEffect, useCallback } from "react";
2-
import { Plus, Trash2 } from "lucide-react";
2+
import { Plus, Trash2, Pencil, Check, X, Loader2 } from "lucide-react";
33
import type { ProvidersConfigMap } from "../types";
44
import { SUPPORTED_PROVIDERS, PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers";
55

@@ -8,10 +8,18 @@ interface NewModelForm {
88
modelId: string;
99
}
1010

11+
interface EditingState {
12+
provider: string;
13+
originalModelId: string;
14+
newModelId: string;
15+
}
16+
1117
export function ModelsSection() {
12-
const [config, setConfig] = useState<ProvidersConfigMap>({});
18+
const [config, setConfig] = useState<ProvidersConfigMap | null>(null);
1319
const [newModel, setNewModel] = useState<NewModelForm>({ provider: "", modelId: "" });
1420
const [saving, setSaving] = useState(false);
21+
const [editing, setEditing] = useState<EditingState | null>(null);
22+
const [error, setError] = useState<string | null>(null);
1523

1624
// Load config on mount
1725
useEffect(() => {
@@ -21,26 +29,32 @@ export function ModelsSection() {
2129
})();
2230
}, []);
2331

24-
// Get all custom models across providers
25-
const getAllModels = (): Array<{ provider: string; modelId: string }> => {
26-
const models: Array<{ provider: string; modelId: string }> = [];
27-
for (const [provider, providerConfig] of Object.entries(config)) {
28-
if (providerConfig.models) {
29-
for (const modelId of providerConfig.models) {
30-
models.push({ provider, modelId });
31-
}
32-
}
33-
}
34-
return models;
35-
};
32+
// Check if a model already exists (for duplicate prevention)
33+
const modelExists = useCallback(
34+
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
35+
if (!config) return false;
36+
const currentModels = config[provider]?.models ?? [];
37+
return currentModels.some((m) => m === modelId && m !== excludeOriginal);
38+
},
39+
[config]
40+
);
3641

3742
const handleAddModel = useCallback(async () => {
38-
if (!newModel.provider || !newModel.modelId.trim()) return;
43+
if (!config || !newModel.provider || !newModel.modelId.trim()) return;
44+
45+
const trimmedModelId = newModel.modelId.trim();
46+
47+
// Check for duplicates
48+
if (modelExists(newModel.provider, trimmedModelId)) {
49+
setError(`Model "${trimmedModelId}" already exists for this provider`);
50+
return;
51+
}
3952

53+
setError(null);
4054
setSaving(true);
4155
try {
4256
const currentModels = config[newModel.provider]?.models ?? [];
43-
const updatedModels = [...currentModels, newModel.modelId.trim()];
57+
const updatedModels = [...currentModels, trimmedModelId];
4458

4559
await window.api.providers.setModels(newModel.provider, updatedModels);
4660

@@ -54,10 +68,11 @@ export function ModelsSection() {
5468
} finally {
5569
setSaving(false);
5670
}
57-
}, [newModel, config]);
71+
}, [newModel, config, modelExists]);
5872

5973
const handleRemoveModel = useCallback(
6074
async (provider: string, modelId: string) => {
75+
if (!config) return;
6176
setSaving(true);
6277
try {
6378
const currentModels = config[provider]?.models ?? [];
@@ -78,6 +93,78 @@ export function ModelsSection() {
7893
[config]
7994
);
8095

96+
const handleStartEdit = useCallback((provider: string, modelId: string) => {
97+
setEditing({ provider, originalModelId: modelId, newModelId: modelId });
98+
setError(null);
99+
}, []);
100+
101+
const handleCancelEdit = useCallback(() => {
102+
setEditing(null);
103+
setError(null);
104+
}, []);
105+
106+
const handleSaveEdit = useCallback(async () => {
107+
if (!config || !editing) return;
108+
109+
const trimmedModelId = editing.newModelId.trim();
110+
if (!trimmedModelId) {
111+
setError("Model ID cannot be empty");
112+
return;
113+
}
114+
115+
// Only validate duplicates if the model ID actually changed
116+
if (trimmedModelId !== editing.originalModelId) {
117+
if (modelExists(editing.provider, trimmedModelId)) {
118+
setError(`Model "${trimmedModelId}" already exists for this provider`);
119+
return;
120+
}
121+
}
122+
123+
setError(null);
124+
setSaving(true);
125+
try {
126+
const currentModels = config[editing.provider]?.models ?? [];
127+
const updatedModels = currentModels.map((m) =>
128+
m === editing.originalModelId ? trimmedModelId : m
129+
);
130+
131+
await window.api.providers.setModels(editing.provider, updatedModels);
132+
133+
// Refresh config
134+
const cfg = await window.api.providers.getConfig();
135+
setConfig(cfg);
136+
setEditing(null);
137+
138+
// Notify other components about the change
139+
window.dispatchEvent(new Event("providers-config-changed"));
140+
} finally {
141+
setSaving(false);
142+
}
143+
}, [editing, config, modelExists]);
144+
145+
// Show loading state while config is being fetched
146+
if (config === null) {
147+
return (
148+
<div className="flex items-center justify-center gap-2 py-12">
149+
<Loader2 className="text-muted h-5 w-5 animate-spin" />
150+
<span className="text-muted text-sm">Loading settings...</span>
151+
</div>
152+
);
153+
}
154+
155+
// Get all custom models across providers
156+
const getAllModels = (): Array<{ provider: string; modelId: string }> => {
157+
const models: Array<{ provider: string; modelId: string }> = [];
158+
for (const [provider, providerConfig] of Object.entries(config)) {
159+
if (providerConfig.models) {
160+
for (const modelId of providerConfig.models) {
161+
models.push({ provider, modelId });
162+
}
163+
}
164+
}
165+
return models;
166+
};
167+
81168
const allModels = getAllModels();
82169

83170
return (
@@ -122,6 +209,7 @@ export function ModelsSection() {
122209
Add
123210
</button>
124211
</div>
212+
{error && !editing && <div className="text-error mt-2 text-xs">{error}</div>}
125213
</div>
126214

127215
{/* List of custom models */}
@@ -130,29 +218,93 @@ export function ModelsSection() {
130218
<div className="text-muted text-xs font-medium tracking-wide uppercase">
131219
Custom Models
132220
</div>
133-
{allModels.map(({ provider, modelId }) => (
134-
<div
135-
key={`${provider}-${modelId}`}
136-
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
137-
>
138-
<div className="flex items-center gap-3">
139-
<span className="text-muted text-xs">
140-
{PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ??
141-
provider}
142-
</span>
143-
<span className="text-foreground font-mono text-sm">{modelId}</span>
144-
</div>
145-
<button
146-
type="button"
147-
onClick={() => void handleRemoveModel(provider, modelId)}
148-
disabled={saving}
149-
className="text-muted hover:text-error p-1 transition-colors"
150-
title="Remove model"
221+
{allModels.map(({ provider, modelId }) => {
222+
const isEditing =
223+
editing?.provider === provider && editing?.originalModelId === modelId;
224+
225+
return (
226+
<div
227+
key={`${provider}-${modelId}`}
228+
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
151229
>
152-
<Trash2 className="h-4 w-4" />
153-
</button>
154-
</div>
155-
))}
230+
<div className="flex min-w-0 flex-1 items-center gap-3">
231+
<span className="text-muted shrink-0 text-xs">
232+
{PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ??
233+
provider}
234+
</span>
235+
{isEditing ? (
236+
<div className="flex min-w-0 flex-1 flex-col gap-1">
237+
<input
238+
type="text"
239+
value={editing.newModelId}
240+
onChange={(e) =>
241+
setEditing((prev) =>
242+
prev ? { ...prev, newModelId: e.target.value } : null
243+
)
244+
}
245+
onKeyDown={(e) => {
246+
if (e.key === "Enter") void handleSaveEdit();
247+
if (e.key === "Escape") handleCancelEdit();
248+
}}
249+
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"
250+
autoFocus
251+
/>
252+
{error && <div className="text-error text-xs">{error}</div>}
253+
</div>
254+
) : (
255+
<span className="text-foreground min-w-0 truncate font-mono text-sm">
256+
{modelId}
257+
</span>
258+
)}
259+
</div>
260+
<div className="ml-2 flex shrink-0 items-center gap-1">
261+
{isEditing ? (
262+
<>
263+
<button
264+
type="button"
265+
onClick={() => void handleSaveEdit()}
266+
disabled={saving}
267+
className="text-accent hover:text-accent-dark p-1 transition-colors"
268+
title="Save changes (Enter)"
269+
>
270+
<Check className="h-4 w-4" />
271+
</button>
272+
<button
273+
type="button"
274+
onClick={handleCancelEdit}
275+
disabled={saving}
276+
className="text-muted hover:text-foreground p-1 transition-colors"
277+
title="Cancel (Escape)"
278+
>
279+
<X className="h-4 w-4" />
280+
</button>
281+
</>
282+
) : (
283+
<>
284+
<button
285+
type="button"
286+
onClick={() => handleStartEdit(provider, modelId)}
287+
disabled={saving || editing !== null}
288+
className="text-muted hover:text-foreground p-1 transition-colors disabled:opacity-50"
289+
title="Edit model"
290+
>
291+
<Pencil className="h-4 w-4" />
292+
</button>
293+
<button
294+
type="button"
295+
onClick={() => void handleRemoveModel(provider, modelId)}
296+
disabled={saving || editing !== null}
297+
className="text-muted hover:text-error p-1 transition-colors disabled:opacity-50"
298+
title="Remove model"
299+
>
300+
<Trash2 className="h-4 w-4" />
301+
</button>
302+
</>
303+
)}
304+
</div>
305+
</div>
306+
);
307+
})}
156308
</div>
157309
) : (
158310
<div className="text-muted py-8 text-center text-sm">

0 commit comments

Comments
 (0)