fix(settings): persist per-model visibility and sibling selector state (#1700)
* fix(settings): persist per-model visibility and sibling selector state
The server-side settings sanitizer only allowlisted favoriteModels and
recentModels, so hiddenModels, collapsedModelProviders, recentAgents, and
recentEfforts were stripped on every write to settings.json — per-model
visibility and collapsed-provider state silently reset on every container
redeploy or settings reload.
Add the four missing fields to sanitizeSettingsUpdate:
- hiddenModels: sanitizeModelRefs(..., 1024) — same shape as favoriteModels;
1024 covers dense multi-provider setups while bounding persistence/memory.
- collapsedModelProviders: normalizeStringArray with Array.isArray gate
(matches usageDropdownProviders).
- recentAgents: normalizeStringArray (Array<string> per ui-store).
- recentEfforts: new sanitizeRecentEfforts validating Record<string, string[]>
(shape confirmed in ui-store + addRecentEffort action); trims/dedupes keys
and variants, caps at 128 keys x 5 variants/key (5 matches client slice).
No ui-store version bump or migration: zustand's default merge spreads
persisted state over defaults, so missing fields fall back to [] / {} until
the next toggle. favoriteModels and recentModels are untouched.
Tests: 8 new cases in settings-helpers.test.js using the real
sanitizeModelRefs / normalizeStringArray — round-trips, empty-[] parity with
favoriteModels, garbage rejection, and a full-payload regression test.
* fix: sync model selector settings
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
307808bec2
commit
eae09d4576
@@ -3,6 +3,14 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string };
|
||||
type ModelPrefsPayload = {
|
||||
favoriteModels: ModelRef[];
|
||||
hiddenModels: ModelRef[];
|
||||
collapsedModelProviders: string[];
|
||||
recentModels: ModelRef[];
|
||||
recentAgents: string[];
|
||||
recentEfforts: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => {
|
||||
if (a === b) return true;
|
||||
@@ -14,6 +22,52 @@ const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const stringsEqual = (a: string[], b: string[]): boolean => {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const recentEffortsEqual = (a: Record<string, string[]>, b: Record<string, string[]>): boolean => {
|
||||
if (a === b) return true;
|
||||
const aKeys = Object.keys(a);
|
||||
if (aKeys.length !== Object.keys(b).length) return false;
|
||||
return aKeys.every((key) => Array.isArray(b[key]) && stringsEqual(a[key], b[key]));
|
||||
};
|
||||
|
||||
const snapshotModelPrefs = (): ModelPrefsPayload => {
|
||||
const state = useUIStore.getState();
|
||||
return {
|
||||
favoriteModels: state.favoriteModels,
|
||||
hiddenModels: state.hiddenModels,
|
||||
collapsedModelProviders: state.collapsedModelProviders,
|
||||
recentModels: state.recentModels,
|
||||
recentAgents: state.recentAgents,
|
||||
recentEfforts: state.recentEfforts,
|
||||
};
|
||||
};
|
||||
|
||||
const modelPrefsEqual = (a: ModelPrefsPayload, b: ModelPrefsPayload): boolean => (
|
||||
refsEqual(a.favoriteModels, b.favoriteModels) &&
|
||||
refsEqual(a.hiddenModels, b.hiddenModels) &&
|
||||
stringsEqual(a.collapsedModelProviders, b.collapsedModelProviders) &&
|
||||
refsEqual(a.recentModels, b.recentModels) &&
|
||||
stringsEqual(a.recentAgents, b.recentAgents) &&
|
||||
recentEffortsEqual(a.recentEfforts, b.recentEfforts)
|
||||
);
|
||||
|
||||
const cloneModelPrefs = (prefs: ModelPrefsPayload): ModelPrefsPayload => ({
|
||||
favoriteModels: prefs.favoriteModels.slice(),
|
||||
hiddenModels: prefs.hiddenModels.slice(),
|
||||
collapsedModelProviders: prefs.collapsedModelProviders.slice(),
|
||||
recentModels: prefs.recentModels.slice(),
|
||||
recentAgents: prefs.recentAgents.slice(),
|
||||
recentEfforts: Object.fromEntries(Object.entries(prefs.recentEfforts).map(([key, variants]) => [key, variants.slice()])),
|
||||
});
|
||||
|
||||
export const startModelPrefsAutoSave = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
@@ -23,26 +77,18 @@ export const startModelPrefsAutoSave = () => {
|
||||
}
|
||||
|
||||
let timer: number | null = null;
|
||||
let lastSent: { favoriteModels: ModelRef[]; recentModels: ModelRef[] } | null = null;
|
||||
let lastSent: ModelPrefsPayload | null = null;
|
||||
let didSkipInitial = false;
|
||||
|
||||
const flush = () => {
|
||||
timer = null;
|
||||
const state = useUIStore.getState();
|
||||
const payload = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
|
||||
const payload = snapshotModelPrefs();
|
||||
|
||||
if (
|
||||
lastSent &&
|
||||
refsEqual(lastSent.favoriteModels, payload.favoriteModels) &&
|
||||
refsEqual(lastSent.recentModels, payload.recentModels)
|
||||
) {
|
||||
if (lastSent && modelPrefsEqual(lastSent, payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSent = {
|
||||
favoriteModels: payload.favoriteModels.slice(),
|
||||
recentModels: payload.recentModels.slice(),
|
||||
};
|
||||
lastSent = cloneModelPrefs(payload);
|
||||
|
||||
void updateDesktopSettings(payload).catch(() => {});
|
||||
};
|
||||
@@ -59,9 +105,23 @@ export const startModelPrefsAutoSave = () => {
|
||||
};
|
||||
|
||||
const unsubscribe = useUIStore.subscribe((state, prevState) => {
|
||||
const next = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
|
||||
const prev = { favoriteModels: prevState.favoriteModels, recentModels: prevState.recentModels };
|
||||
if (refsEqual(next.favoriteModels, prev.favoriteModels) && refsEqual(next.recentModels, prev.recentModels)) {
|
||||
const next = {
|
||||
favoriteModels: state.favoriteModels,
|
||||
hiddenModels: state.hiddenModels,
|
||||
collapsedModelProviders: state.collapsedModelProviders,
|
||||
recentModels: state.recentModels,
|
||||
recentAgents: state.recentAgents,
|
||||
recentEfforts: state.recentEfforts,
|
||||
};
|
||||
const prev = {
|
||||
favoriteModels: prevState.favoriteModels,
|
||||
hiddenModels: prevState.hiddenModels,
|
||||
collapsedModelProviders: prevState.collapsedModelProviders,
|
||||
recentModels: prevState.recentModels,
|
||||
recentAgents: prevState.recentAgents,
|
||||
recentEfforts: prevState.recentEfforts,
|
||||
};
|
||||
if (modelPrefsEqual(next, prev)) {
|
||||
return;
|
||||
}
|
||||
schedule();
|
||||
|
||||
Reference in New Issue
Block a user