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
@@ -203,6 +203,42 @@ const areStringRecordsEqual = (left: Record<string, string>, right: Record<strin
|
||||
return leftEntries.every(([key, value]) => right[key] === value);
|
||||
};
|
||||
|
||||
const areModelRefsEqual = (
|
||||
left: Array<{ providerID: string; modelID: string }>,
|
||||
right: Array<{ providerID: string; modelID: string }>,
|
||||
): boolean => (
|
||||
left.length === right.length &&
|
||||
left.every((item, idx) => item.providerID === right[idx]?.providerID && item.modelID === right[idx]?.modelID)
|
||||
);
|
||||
|
||||
const areStringArraysEqual = (left: string[], right: string[]): boolean => (
|
||||
left.length === right.length && left.every((value, idx) => value === right[idx])
|
||||
);
|
||||
|
||||
const sanitizeStringArray = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return Array.from(new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)));
|
||||
};
|
||||
|
||||
const sanitizeRecentEfforts = (value: unknown): Record<string, string[]> | undefined => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, variants] of Object.entries(value)) {
|
||||
if (!key || !Array.isArray(variants)) continue;
|
||||
const sanitized = sanitizeStringArray(variants);
|
||||
if (sanitized && sanitized.length > 0) {
|
||||
result[key] = sanitized.slice(0, 5);
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const areRecentEffortsEqual = (left: Record<string, string[]>, right: Record<string, string[]>): boolean => {
|
||||
const leftKeys = Object.keys(left);
|
||||
if (leftKeys.length !== Object.keys(right).length) return false;
|
||||
return leftKeys.every((key) => Array.isArray(right[key]) && areStringArraysEqual(left[key], right[key]));
|
||||
};
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
||||
|
||||
const normalizeIconBackground = (value: unknown): string | null => {
|
||||
@@ -601,24 +637,50 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (Array.isArray(settings.favoriteModels)) {
|
||||
const current = store.favoriteModels;
|
||||
const next = settings.favoriteModels;
|
||||
const same =
|
||||
current.length === next.length &&
|
||||
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
|
||||
if (!same) {
|
||||
if (!areModelRefsEqual(current, next)) {
|
||||
useUIStore.setState({ favoriteModels: next });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.hiddenModels)) {
|
||||
const current = store.hiddenModels;
|
||||
const next = settings.hiddenModels;
|
||||
if (!areModelRefsEqual(current, next)) {
|
||||
useUIStore.setState({ hiddenModels: next });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.collapsedModelProviders)) {
|
||||
const current = store.collapsedModelProviders;
|
||||
const next = settings.collapsedModelProviders;
|
||||
if (!areStringArraysEqual(current, next)) {
|
||||
useUIStore.setState({ collapsedModelProviders: next });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.recentModels)) {
|
||||
const current = store.recentModels;
|
||||
const next = settings.recentModels;
|
||||
const same =
|
||||
current.length === next.length &&
|
||||
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
|
||||
if (!same) {
|
||||
if (!areModelRefsEqual(current, next)) {
|
||||
useUIStore.setState({ recentModels: next });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.recentAgents)) {
|
||||
const current = store.recentAgents;
|
||||
const next = settings.recentAgents;
|
||||
if (!areStringArraysEqual(current, next)) {
|
||||
useUIStore.setState({ recentAgents: next });
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.recentEfforts && typeof settings.recentEfforts === 'object') {
|
||||
const current = store.recentEfforts;
|
||||
const next = settings.recentEfforts;
|
||||
if (!areRecentEffortsEqual(current, next)) {
|
||||
useUIStore.setState({ recentEfforts: next });
|
||||
}
|
||||
}
|
||||
if (typeof settings.diffLayoutPreference === 'string'
|
||||
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
|
||||
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
|
||||
@@ -1047,10 +1109,30 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
result.favoriteModels = favoriteModels;
|
||||
}
|
||||
|
||||
const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, 1024);
|
||||
if (hiddenModels) {
|
||||
result.hiddenModels = hiddenModels;
|
||||
}
|
||||
|
||||
const collapsedModelProviders = sanitizeStringArray(candidate.collapsedModelProviders);
|
||||
if (collapsedModelProviders) {
|
||||
result.collapsedModelProviders = collapsedModelProviders;
|
||||
}
|
||||
|
||||
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
||||
if (recentModels) {
|
||||
result.recentModels = recentModels;
|
||||
}
|
||||
|
||||
const recentAgents = sanitizeStringArray(candidate.recentAgents);
|
||||
if (recentAgents) {
|
||||
result.recentAgents = recentAgents;
|
||||
}
|
||||
|
||||
const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
|
||||
if (recentEfforts) {
|
||||
result.recentEfforts = recentEfforts;
|
||||
}
|
||||
if (
|
||||
typeof candidate.diffLayoutPreference === 'string'
|
||||
&& (candidate.diffLayoutPreference === 'dynamic'
|
||||
|
||||
Reference in New Issue
Block a user