diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index b1c9c94..7f2bd6a 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -38,7 +38,24 @@ import { PresetsPanel } from "../components/settings/PresetsPanel" import { getAdminPresets, normalizePresetModel, type ProviderPreset } from "../api" import { TokenUsagePage } from "./TokenUsagePage" import { useWorkspace } from "@/ctx" -import { ArrowLeft, Plus, Trash2, RefreshCw, Check, AlertCircle, Lock, FileCode2, Search, User, Cpu, Terminal, Layers, BarChart3, Users, Globe, Share2, KeyRound, Copy, Wrench, Bookmark } from "lucide-react" +import { ArrowLeft, Plus, Trash2, RefreshCw, Check, AlertCircle, Lock, FileCode2, Search, User, Cpu, Terminal, Layers, BarChart3, Users, Globe, Share2, KeyRound, Copy, Wrench, Bookmark, GripVertical } from "lucide-react" +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" @@ -357,6 +374,31 @@ type ProvidersDraft = { }> } +export function buildProvidersDiskFromDraft( + draft: ProvidersDraft, + providerOrder: string[], +): Record { + const providersOut: Record = {} + if (draft.default) providersOut.default = draft.default + for (const name of providerOrder) { + const p = draft.providers[name] + if (!p) continue + const models: ModelEntry[] = p.models + .filter(m => m.id.trim()) + .map(m => ({ + id: m.id.trim(), + ...(m.maxContextTokens && m.maxContextTokens > 0 ? { maxContextTokens: m.maxContextTokens } : {}), + })) + providersOut[name] = { + baseUrl: p.baseUrl, + apiKey: `\${${providerEnvVarName(name)}}`, + ...(models.length > 0 ? { models } : {}), + ...(p.enabled ? {} : { enabled: false }), + } + } + return providersOut +} + function ProvidersSection({ disk, refExists, onChanged, disabled }: { disk: PersonalConfigDisk | null refExists: RefExistsMap @@ -378,12 +420,18 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { const [testingModel, setTestingModel] = useState>({}) const [testError, setTestError] = useState>({}) const [providerPresets, setProviderPresets] = useState([]) + const [providerOrder, setProviderOrder] = useState([]) + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) useEffect(() => { getAdminPresets().then(d => setProviderPresets(d.providerPresets)).catch(() => {}) }, []) useEffect(() => { - if (!disk) { setDraft(null); setSaved(false); return } + if (!disk) { setDraft(null); setProviderOrder([]); setSaved(false); return } const next: ProvidersDraft = { default: "", providers: {} } + const order: string[] = [] for (const [name, val] of Object.entries(disk.providers)) { if (name === "default") { if (typeof val === "string") next.default = val @@ -401,12 +449,14 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { apiKeyNewValue: "", apiKeyStored: !!refInfo?.exists, } + order.push(name) } } setDraft(next) + setProviderOrder(order) }, [disk, refExists]) - const names = draft ? Object.keys(draft.providers) : [] + const names = providerOrder.filter((name) => !!draft?.providers[name]) const updateProv = (name: string, patch: Partial) => { setDraft((d) => { @@ -422,6 +472,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { const clearDefault = d.default === name || d.default.startsWith(`${name}/`) return { ...d, providers: rest, default: clearDefault ? "" : d.default } }) + setProviderOrder((order) => order.filter((n) => n !== name)) } const updateModel = (provName: string, modelId: string, patch: Partial) => { @@ -482,6 +533,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { const renameProvider = (oldName: string) => { const newName = provRenameValue.trim() if (!newName || newName === oldName || newName === "default") { setEditingProvName(null); return } + if (!draft || !draft.providers[oldName] || draft.providers[newName]) { setEditingProvName(null); return } setDraft((d) => { if (!d || !d.providers[oldName]) return d if (d.providers[newName]) return d @@ -495,6 +547,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { } return { ...d, default: newDefault, providers: { ...rest, [newName]: prov } } }) + setProviderOrder((order) => order.map((name) => name === oldName ? newName : name)) setEditingProvName(null) } @@ -510,6 +563,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { apiKeyNewValue: "", apiKeyStored: false, } } } }) + setProviderOrder((order) => order.includes(n) ? order : [...order, n]) setNewName("") setAdding(false) setErr(null) @@ -528,22 +582,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { const r = await writeVaultEnv(varName, p.apiKeyNewValue.trim()) if (!r.ok) { setErr(`apiKey write failed for "${name}": ${r.error}`); setSaving(false); return } } - const providersOut: Record = {} - if (draft.default) providersOut.default = draft.default - for (const [name, p] of Object.entries(draft.providers)) { - const models: ModelEntry[] = p.models - .filter(m => m.id.trim()) - .map(m => ({ - id: m.id.trim(), - ...(m.maxContextTokens && m.maxContextTokens > 0 ? { maxContextTokens: m.maxContextTokens } : {}), - })) - providersOut[name] = { - baseUrl: p.baseUrl, - apiKey: `\${${providerEnvVarName(name)}}`, - ...(models.length > 0 ? { models } : {}), - ...(p.enabled ? {} : { enabled: false }), - } - } + const providersOut = buildProvidersDiskFromDraft(draft, providerOrder) const r = await savePersonalDisk({ providers: providersOut }) if (!r.ok) { setSaving(false); setErr(r.error ?? "save failed"); return } // Write-through: personal is a per-user repo — push the change to the remote @@ -563,18 +602,42 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { onChanged() } + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + setProviderOrder((order) => { + const oldIdx = order.indexOf(String(active.id)) + const newIdx = order.indexOf(String(over.id)) + if (oldIdx < 0 || newIdx < 0) return order + return arrayMove(order, oldIdx, newIdx) + }) + } + if (!draft) return
no providers yet
return (
+ + {names.map((name) => { const p = draft.providers[name] const isAddingModel = addingModel[name] ?? false const hasKey = p.apiKeyStored || p.apiKeyNewValue.trim() !== "" return ( -
+ + {(dragHandleProps) => ( + <> {/* Provider header */}
+
-
+ + )} + ) })} + + {/* Preset provider shortcuts */}
@@ -806,6 +873,7 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { }, } }) + setProviderOrder((order) => order.includes(p.name) ? order : [...order, p.name]) }} className="px-2 py-0.5 rounded border border-gray-200 bg-white text-[10px] text-gray-500 hover:text-gray-900 hover:border-gray-400 transition-colors" title={`Add ${p.name} preset`} @@ -849,6 +917,30 @@ function ProvidersSection({ disk, refExists, onChanged, disabled }: { ) } +function SortableProviderCard({ + id, + children, +}: { + id: string + children: (dragHandleProps: Record) => ReactNode +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + } + return ( +
+ {children({ ...attributes, ...listeners })} +
+ ) +} + function Labeled({ label, children, className }: { label: string; children: ReactNode; className?: string }) { return (
) } - diff --git a/web/test/SettingsPage.provider-order.test.ts b/web/test/SettingsPage.provider-order.test.ts new file mode 100644 index 0000000..4f5cb02 --- /dev/null +++ b/web/test/SettingsPage.provider-order.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" + +import type { ModelEntry, ProviderDisk } from "../src/api" + +type ProviderDraftForTest = { + models: ModelEntry[] + baseUrl: string + enabled: boolean + apiKeyNewValue: string + apiKeyStored: boolean +} + +describe("provider order persistence", () => { + test("serializes providers in explicit fallback order while keeping default first", async () => { + const { buildProvidersDiskFromDraft } = await import("../src/pages/SettingsPage") + + const draft = { + default: "beta/beta-model", + providers: { + alpha: { + models: [{ id: "alpha-model" }], + baseUrl: "https://alpha.example", + enabled: true, + apiKeyNewValue: "", + apiKeyStored: true, + }, + beta: { + models: [{ id: "beta-model", maxContextTokens: 123 }], + baseUrl: "https://beta.example", + enabled: false, + apiKeyNewValue: "", + apiKeyStored: true, + }, + } satisfies Record, + } + + const providersOut = buildProvidersDiskFromDraft(draft, ["beta", "alpha", "missing"]) + + expect(Object.keys(providersOut)).toEqual(["default", "beta", "alpha"]) + expect(providersOut.default).toBe("beta/beta-model") + expect(providersOut.beta as ProviderDisk).toEqual({ + baseUrl: "https://beta.example", + apiKey: "${BETA_API_KEY}", + models: [{ id: "beta-model", maxContextTokens: 123 }], + enabled: false, + }) + expect(providersOut.alpha as ProviderDisk).toEqual({ + baseUrl: "https://alpha.example", + apiKey: "${ALPHA_API_KEY}", + models: [{ id: "alpha-model" }], + }) + }) +})