Skip to content

Commit 637f4e5

Browse files
committed
🤖 feat: add model editing capabilities to settings
- 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`_
1 parent a2a95af commit 637f4e5

File tree

1 file changed

+165
-25
lines changed

1 file changed

+165
-25
lines changed

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

Lines changed: 165 additions & 25 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 } 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() {
1218
const [config, setConfig] = useState<ProvidersConfigMap>({});
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(() => {
@@ -34,13 +42,31 @@ export function ModelsSection() {
3442
return models;
3543
};
3644

45+
// Check if a model already exists (for duplicate prevention)
46+
const modelExists = useCallback(
47+
(provider: string, modelId: string, excludeOriginal?: string): boolean => {
48+
const currentModels = config[provider]?.models ?? [];
49+
return currentModels.some((m) => m === modelId && m !== excludeOriginal);
50+
},
51+
[config]
52+
);
53+
3754
const handleAddModel = useCallback(async () => {
3855
if (!newModel.provider || !newModel.modelId.trim()) return;
3956

57+
const trimmedModelId = newModel.modelId.trim();
58+
59+
// Check for duplicates
60+
if (modelExists(newModel.provider, trimmedModelId)) {
61+
setError(`Model "${trimmedModelId}" already exists for this provider`);
62+
return;
63+
}
64+
65+
setError(null);
4066
setSaving(true);
4167
try {
4268
const currentModels = config[newModel.provider]?.models ?? [];
43-
const updatedModels = [...currentModels, newModel.modelId.trim()];
69+
const updatedModels = [...currentModels, trimmedModelId];
4470

4571
await window.api.providers.setModels(newModel.provider, updatedModels);
4672

@@ -54,7 +80,7 @@ export function ModelsSection() {
5480
} finally {
5581
setSaving(false);
5682
}
57-
}, [newModel, config]);
83+
}, [newModel, config, modelExists]);
5884

5985
const handleRemoveModel = useCallback(
6086
async (provider: string, modelId: string) => {
@@ -78,6 +104,55 @@ export function ModelsSection() {
78104
[config]
79105
);
80106

107+
const handleStartEdit = useCallback((provider: string, modelId: string) => {
108+
setEditing({ provider, originalModelId: modelId, newModelId: modelId });
109+
setError(null);
110+
}, []);
111+
112+
const handleCancelEdit = useCallback(() => {
113+
setEditing(null);
114+
setError(null);
115+
}, []);
116+
117+
const handleSaveEdit = useCallback(async () => {
118+
if (!editing) return;
119+
120+
const trimmedModelId = editing.newModelId.trim();
121+
if (!trimmedModelId) {
122+
setError("Model ID cannot be empty");
123+
return;
124+
}
125+
126+
// Only validate duplicates if the model ID actually changed
127+
if (trimmedModelId !== editing.originalModelId) {
128+
if (modelExists(editing.provider, trimmedModelId)) {
129+
setError(`Model "${trimmedModelId}" already exists for this provider`);
130+
return;
131+
}
132+
}
133+
134+
setError(null);
135+
setSaving(true);
136+
try {
137+
const currentModels = config[editing.provider]?.models ?? [];
138+
const updatedModels = currentModels.map((m) =>
139+
m === editing.originalModelId ? trimmedModelId : m
140+
);
141+
142+
await window.api.providers.setModels(editing.provider, updatedModels);
143+
144+
// Refresh config
145+
const cfg = await window.api.providers.getConfig();
146+
setConfig(cfg);
147+
setEditing(null);
148+
149+
// Notify other components about the change
150+
window.dispatchEvent(new Event("providers-config-changed"));
151+
} finally {
152+
setSaving(false);
153+
}
154+
}, [editing, config, modelExists]);
155+
81156
const allModels = getAllModels();
82157

83158
return (
@@ -122,6 +197,7 @@ export function ModelsSection() {
122197
Add
123198
</button>
124199
</div>
200+
{error && !editing && <div className="text-error mt-2 text-xs">{error}</div>}
125201
</div>
126202

127203
{/* List of custom models */}
@@ -130,29 +206,93 @@ export function ModelsSection() {
130206
<div className="text-muted text-xs font-medium tracking-wide uppercase">
131207
Custom Models
132208
</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"
209+
{allModels.map(({ provider, modelId }) => {
210+
const isEditing =
211+
editing?.provider === provider && editing?.originalModelId === modelId;
212+
213+
return (
214+
<div
215+
key={`${provider}-${modelId}`}
216+
className="border-border-medium bg-background-secondary flex items-center justify-between rounded-md border px-4 py-2"
151217
>
152-
<Trash2 className="h-4 w-4" />
153-
</button>
154-
</div>
155-
))}
218+
<div className="flex min-w-0 flex-1 items-center gap-3">
219+
<span className="text-muted shrink-0 text-xs">
220+
{PROVIDER_DISPLAY_NAMES[provider as keyof typeof PROVIDER_DISPLAY_NAMES] ??
221+
provider}
222+
</span>
223+
{isEditing ? (
224+
<div className="flex min-w-0 flex-1 flex-col gap-1">
225+
<input
226+
type="text"
227+
value={editing.newModelId}
228+
onChange={(e) =>
229+
setEditing((prev) =>
230+
prev ? { ...prev, newModelId: e.target.value } : null
231+
)
232+
}
233+
onKeyDown={(e) => {
234+
if (e.key === "Enter") void handleSaveEdit();
235+
if (e.key === "Escape") handleCancelEdit();
236+
}}
237+
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"
238+
autoFocus
239+
/>
240+
{error && <div className="text-error text-xs">{error}</div>}
241+
</div>
242+
) : (
243+
<span className="text-foreground min-w-0 truncate font-mono text-sm">
244+
{modelId}
245+
</span>
246+
)}
247+
</div>
248+
<div className="ml-2 flex shrink-0 items-center gap-1">
249+
{isEditing ? (
250+
<>
251+
<button
252+
type="button"
253+
onClick={() => void handleSaveEdit()}
254+
disabled={saving}
255+
className="text-accent hover:text-accent-dark p-1 transition-colors"
256+
title="Save changes (Enter)"
257+
>
258+
<Check className="h-4 w-4" />
259+
</button>
260+
<button
261+
type="button"
262+
onClick={handleCancelEdit}
263+
disabled={saving}
264+
className="text-muted hover:text-foreground p-1 transition-colors"
265+
title="Cancel (Escape)"
266+
>
267+
<X className="h-4 w-4" />
268+
</button>
269+
</>
270+
) : (
271+
<>
272+
<button
273+
type="button"
274+
onClick={() => handleStartEdit(provider, modelId)}
275+
disabled={saving || editing !== null}
276+
className="text-muted hover:text-foreground p-1 transition-colors disabled:opacity-50"
277+
title="Edit model"
278+
>
279+
<Pencil className="h-4 w-4" />
280+
</button>
281+
<button
282+
type="button"
283+
onClick={() => void handleRemoveModel(provider, modelId)}
284+
disabled={saving || editing !== null}
285+
className="text-muted hover:text-error p-1 transition-colors disabled:opacity-50"
286+
title="Remove model"
287+
>
288+
<Trash2 className="h-4 w-4" />
289+
</button>
290+
</>
291+
)}
292+
</div>
293+
</div>
294+
);
295+
})}
156296
</div>
157297
) : (
158298
<div className="text-muted py-8 text-center text-sm">

0 commit comments

Comments
 (0)