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
@@ -158,7 +158,11 @@ export type DesktopSettings = {
|
|||||||
shortcutOverrides?: Record<string, string>;
|
shortcutOverrides?: Record<string, string>;
|
||||||
|
|
||||||
favoriteModels?: Array<{ providerID: string; modelID: string }>;
|
favoriteModels?: Array<{ providerID: string; modelID: string }>;
|
||||||
|
hiddenModels?: Array<{ providerID: string; modelID: string }>;
|
||||||
|
collapsedModelProviders?: string[];
|
||||||
recentModels?: Array<{ providerID: string; modelID: string }>;
|
recentModels?: Array<{ providerID: string; modelID: string }>;
|
||||||
|
recentAgents?: string[];
|
||||||
|
recentEfforts?: Record<string, string[]>;
|
||||||
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
|
||||||
gitChangesViewMode?: 'flat' | 'tree';
|
gitChangesViewMode?: 'flat' | 'tree';
|
||||||
directoryShowHidden?: boolean;
|
directoryShowHidden?: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
|||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
type ModelRef = { providerID: string; modelID: string };
|
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 => {
|
const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
@@ -14,6 +22,52 @@ const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => {
|
|||||||
return true;
|
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 = () => {
|
export const startModelPrefsAutoSave = () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return () => {};
|
return () => {};
|
||||||
@@ -23,26 +77,18 @@ export const startModelPrefsAutoSave = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
let lastSent: { favoriteModels: ModelRef[]; recentModels: ModelRef[] } | null = null;
|
let lastSent: ModelPrefsPayload | null = null;
|
||||||
let didSkipInitial = false;
|
let didSkipInitial = false;
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
timer = null;
|
timer = null;
|
||||||
const state = useUIStore.getState();
|
const payload = snapshotModelPrefs();
|
||||||
const payload = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
|
|
||||||
|
|
||||||
if (
|
if (lastSent && modelPrefsEqual(lastSent, payload)) {
|
||||||
lastSent &&
|
|
||||||
refsEqual(lastSent.favoriteModels, payload.favoriteModels) &&
|
|
||||||
refsEqual(lastSent.recentModels, payload.recentModels)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSent = {
|
lastSent = cloneModelPrefs(payload);
|
||||||
favoriteModels: payload.favoriteModels.slice(),
|
|
||||||
recentModels: payload.recentModels.slice(),
|
|
||||||
};
|
|
||||||
|
|
||||||
void updateDesktopSettings(payload).catch(() => {});
|
void updateDesktopSettings(payload).catch(() => {});
|
||||||
};
|
};
|
||||||
@@ -59,9 +105,23 @@ export const startModelPrefsAutoSave = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unsubscribe = useUIStore.subscribe((state, prevState) => {
|
const unsubscribe = useUIStore.subscribe((state, prevState) => {
|
||||||
const next = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
|
const next = {
|
||||||
const prev = { favoriteModels: prevState.favoriteModels, recentModels: prevState.recentModels };
|
favoriteModels: state.favoriteModels,
|
||||||
if (refsEqual(next.favoriteModels, prev.favoriteModels) && refsEqual(next.recentModels, prev.recentModels)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
schedule();
|
schedule();
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types';
|
import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types';
|
||||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { applyPersistedHomeDirectoryToWindow, updateDesktopSettings } from './persistence';
|
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { applyPersistedHomeDirectoryToWindow, syncDesktopSettings, updateDesktopSettings } from './persistence';
|
||||||
|
|
||||||
type TestWindow = {
|
type TestWindow = {
|
||||||
__OPENCHAMBER_HOME__?: string;
|
__OPENCHAMBER_HOME__?: string;
|
||||||
dispatchEvent: (event: Event) => boolean;
|
dispatchEvent: (event: Event) => boolean;
|
||||||
|
setTimeout: typeof setTimeout;
|
||||||
|
clearTimeout: typeof clearTimeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
let createdWindow = false;
|
let createdWindow = false;
|
||||||
@@ -48,22 +52,42 @@ const getWindow = (): TestWindow => {
|
|||||||
}
|
}
|
||||||
const testWindow = window as unknown as Partial<TestWindow>;
|
const testWindow = window as unknown as Partial<TestWindow>;
|
||||||
testWindow.dispatchEvent ??= () => true;
|
testWindow.dispatchEvent ??= () => true;
|
||||||
|
testWindow.setTimeout ??= setTimeout;
|
||||||
|
testWindow.clearTimeout ??= clearTimeout;
|
||||||
ensureLocalStorage();
|
ensureLocalStorage();
|
||||||
return testWindow as TestWindow;
|
return testWindow as TestWindow;
|
||||||
};
|
};
|
||||||
|
|
||||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const registerSettingsSave = (save: (changes: Partial<SettingsPayload>) => Promise<SettingsPayload>): void => {
|
const registerSettingsApi = (
|
||||||
|
save: (changes: Partial<SettingsPayload>) => Promise<SettingsPayload>,
|
||||||
|
load: () => Promise<{ settings: SettingsPayload; source: 'web' | 'vscode' }> = async () => ({ settings: {}, source: 'web' }),
|
||||||
|
): void => {
|
||||||
registerRuntimeAPIs({
|
registerRuntimeAPIs({
|
||||||
runtime: { platform: 'web', isDesktop: false, isVSCode: false },
|
runtime: { platform: 'web', isDesktop: false, isVSCode: false },
|
||||||
settings: {
|
settings: {
|
||||||
load: async () => ({ settings: {}, source: 'web' }),
|
load,
|
||||||
save,
|
save,
|
||||||
},
|
},
|
||||||
} as unknown as RuntimeAPIs);
|
} as unknown as RuntimeAPIs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const registerSettingsSave = (save: (changes: Partial<SettingsPayload>) => Promise<SettingsPayload>): void => {
|
||||||
|
registerSettingsApi(save);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetModelPrefsState = (): void => {
|
||||||
|
useUIStore.setState({
|
||||||
|
favoriteModels: [],
|
||||||
|
hiddenModels: [],
|
||||||
|
collapsedModelProviders: [],
|
||||||
|
recentModels: [],
|
||||||
|
recentAgents: [],
|
||||||
|
recentEfforts: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
registerRuntimeAPIs(null);
|
registerRuntimeAPIs(null);
|
||||||
if (createdWindow) {
|
if (createdWindow) {
|
||||||
@@ -100,6 +124,7 @@ describe('updateDesktopSettings', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getWindow();
|
getWindow();
|
||||||
registerRuntimeAPIs(null);
|
registerRuntimeAPIs(null);
|
||||||
|
resetModelPrefsState();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('waits for the debounced settings save to finish before resolving', async () => {
|
test('waits for the debounced settings save to finish before resolving', async () => {
|
||||||
@@ -170,4 +195,63 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(firstResolved).toBe(true);
|
expect(firstResolved).toBe(true);
|
||||||
expect(secondResolved).toBe(true);
|
expect(secondResolved).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applies model selector settings from server settings', async () => {
|
||||||
|
getWindow();
|
||||||
|
const settings = {
|
||||||
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
|
||||||
|
hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
||||||
|
collapsedModelProviders: ['anthropic', 'openai'],
|
||||||
|
recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }],
|
||||||
|
recentAgents: ['build', 'plan'],
|
||||||
|
recentEfforts: { 'anthropic/claude-haiku-4': ['high', 'default'] },
|
||||||
|
} satisfies SettingsPayload;
|
||||||
|
registerSettingsApi(async () => ({}), async () => ({ settings, source: 'web' }));
|
||||||
|
|
||||||
|
await syncDesktopSettings();
|
||||||
|
|
||||||
|
const state = useUIStore.getState();
|
||||||
|
expect(state.favoriteModels).toEqual(settings.favoriteModels);
|
||||||
|
expect(state.hiddenModels).toEqual(settings.hiddenModels);
|
||||||
|
expect(state.collapsedModelProviders).toEqual(settings.collapsedModelProviders);
|
||||||
|
expect(state.recentModels).toEqual(settings.recentModels);
|
||||||
|
expect(state.recentAgents).toEqual(settings.recentAgents);
|
||||||
|
expect(state.recentEfforts).toEqual(settings.recentEfforts);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autosaves all model selector settings fields', async () => {
|
||||||
|
getWindow();
|
||||||
|
const saveCalls: Array<Partial<SettingsPayload>> = [];
|
||||||
|
registerSettingsSave(async (changes) => {
|
||||||
|
saveCalls.push(changes);
|
||||||
|
return changes as SettingsPayload;
|
||||||
|
});
|
||||||
|
const stop = startModelPrefsAutoSave();
|
||||||
|
|
||||||
|
try {
|
||||||
|
useUIStore.setState({ favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }] });
|
||||||
|
await delay(20);
|
||||||
|
useUIStore.setState({
|
||||||
|
hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
||||||
|
collapsedModelProviders: ['openai'],
|
||||||
|
recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }],
|
||||||
|
recentAgents: ['build'],
|
||||||
|
recentEfforts: { 'openai/gpt-5': ['low'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(1500);
|
||||||
|
|
||||||
|
expect(saveCalls).toHaveLength(1);
|
||||||
|
expect(saveCalls[0]).toEqual({
|
||||||
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
|
||||||
|
hiddenModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
||||||
|
collapsedModelProviders: ['openai'],
|
||||||
|
recentModels: [{ providerID: 'google', modelID: 'gemini-pro' }],
|
||||||
|
recentAgents: ['build'],
|
||||||
|
recentEfforts: { 'openai/gpt-5': ['low'] },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -203,6 +203,42 @@ const areStringRecordsEqual = (left: Record<string, string>, right: Record<strin
|
|||||||
return leftEntries.every(([key, value]) => right[key] === value);
|
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 HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
||||||
|
|
||||||
const normalizeIconBackground = (value: unknown): string | null => {
|
const normalizeIconBackground = (value: unknown): string | null => {
|
||||||
@@ -601,24 +637,50 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
if (Array.isArray(settings.favoriteModels)) {
|
if (Array.isArray(settings.favoriteModels)) {
|
||||||
const current = store.favoriteModels;
|
const current = store.favoriteModels;
|
||||||
const next = settings.favoriteModels;
|
const next = settings.favoriteModels;
|
||||||
const same =
|
if (!areModelRefsEqual(current, next)) {
|
||||||
current.length === next.length &&
|
|
||||||
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
|
|
||||||
if (!same) {
|
|
||||||
useUIStore.setState({ favoriteModels: 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)) {
|
if (Array.isArray(settings.recentModels)) {
|
||||||
const current = store.recentModels;
|
const current = store.recentModels;
|
||||||
const next = settings.recentModels;
|
const next = settings.recentModels;
|
||||||
const same =
|
if (!areModelRefsEqual(current, next)) {
|
||||||
current.length === next.length &&
|
|
||||||
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
|
|
||||||
if (!same) {
|
|
||||||
useUIStore.setState({ recentModels: 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'
|
if (typeof settings.diffLayoutPreference === 'string'
|
||||||
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
|
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
|
||||||
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
|
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
|
||||||
@@ -1047,10 +1109,30 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
result.favoriteModels = favoriteModels;
|
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);
|
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
||||||
if (recentModels) {
|
if (recentModels) {
|
||||||
result.recentModels = 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 (
|
if (
|
||||||
typeof candidate.diffLayoutPreference === 'string'
|
typeof candidate.diffLayoutPreference === 'string'
|
||||||
&& (candidate.diffLayoutPreference === 'dynamic'
|
&& (candidate.diffLayoutPreference === 'dynamic'
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
|
const SHORTCUT_OVERRIDE_VALUE_MAX_LENGTH = 128;
|
||||||
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
|
||||||
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
|
||||||
|
const HIDDEN_MODELS_MAX = 1024;
|
||||||
|
const RECENT_EFFORTS_MAX_KEYS = 128;
|
||||||
|
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
|
||||||
|
|
||||||
const sanitizeShortcutOverrides = (value) => {
|
const sanitizeShortcutOverrides = (value) => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
@@ -41,6 +44,35 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeRecentEfforts = (value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
const seenKeys = new Set();
|
||||||
|
let count = 0;
|
||||||
|
for (const [rawKey, rawVariants] of Object.entries(value)) {
|
||||||
|
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
||||||
|
if (!key || seenKeys.has(key)) continue;
|
||||||
|
if (!Array.isArray(rawVariants)) continue;
|
||||||
|
const variants = [];
|
||||||
|
const seenVariants = new Set();
|
||||||
|
for (const rawVariant of rawVariants) {
|
||||||
|
const variant = typeof rawVariant === 'string' ? rawVariant.trim() : '';
|
||||||
|
if (!variant || seenVariants.has(variant)) continue;
|
||||||
|
seenVariants.add(variant);
|
||||||
|
variants.push(variant);
|
||||||
|
if (variants.length >= RECENT_EFFORTS_MAX_VARIANTS_PER_KEY) break;
|
||||||
|
}
|
||||||
|
if (variants.length === 0) continue;
|
||||||
|
seenKeys.add(key);
|
||||||
|
result[key] = variants;
|
||||||
|
count += 1;
|
||||||
|
if (count >= RECENT_EFFORTS_MAX_KEYS) break;
|
||||||
|
}
|
||||||
|
return count > 0 ? result : null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizePwaAppName = (value, fallback = '') => {
|
const normalizePwaAppName = (value, fallback = '') => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -474,6 +506,28 @@ export const createSettingsHelpers = (dependencies) => {
|
|||||||
if (recentModels) {
|
if (recentModels) {
|
||||||
result.recentModels = recentModels;
|
result.recentModels = recentModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap at 1024: users with several providers (anthropic, openai, google,
|
||||||
|
// bedrock, azure, etc.) each exposing dozens-to-hundreds of models can
|
||||||
|
// exceed 256 hidden entries quickly. 1024 covers dense multi-provider
|
||||||
|
// setups while still bounding persistence/memory.
|
||||||
|
const hiddenModels = sanitizeModelRefs(candidate.hiddenModels, HIDDEN_MODELS_MAX);
|
||||||
|
if (hiddenModels) {
|
||||||
|
result.hiddenModels = hiddenModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(candidate.collapsedModelProviders)) {
|
||||||
|
result.collapsedModelProviders = normalizeStringArray(candidate.collapsedModelProviders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(candidate.recentAgents)) {
|
||||||
|
result.recentAgents = normalizeStringArray(candidate.recentAgents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentEfforts = sanitizeRecentEfforts(candidate.recentEfforts);
|
||||||
|
if (recentEfforts) {
|
||||||
|
result.recentEfforts = recentEfforts;
|
||||||
|
}
|
||||||
if (typeof candidate.diffLayoutPreference === 'string') {
|
if (typeof candidate.diffLayoutPreference === 'string') {
|
||||||
const mode = candidate.diffLayoutPreference.trim();
|
const mode = candidate.diffLayoutPreference.trim();
|
||||||
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createSettingsHelpers } from './settings-helpers.js';
|
import { createSettingsHelpers } from './settings-helpers.js';
|
||||||
|
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
|
||||||
|
|
||||||
const createTestHelpers = () => createSettingsHelpers({
|
const createTestHelpers = () => createSettingsHelpers({
|
||||||
normalizePathForPersistence: (value) => value,
|
normalizePathForPersistence: (value) => value,
|
||||||
@@ -20,6 +21,42 @@ const createTestHelpers = () => createSettingsHelpers({
|
|||||||
sanitizeProjects: () => undefined,
|
sanitizeProjects: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createTestHelpersWithRealSanitizers = () => {
|
||||||
|
const runtime = createSettingsNormalizationRuntime({
|
||||||
|
os: { homedir: () => '/home/testuser' },
|
||||||
|
path: {
|
||||||
|
resolve: (...args) => args[args.length - 1],
|
||||||
|
sep: '/',
|
||||||
|
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
|
||||||
|
},
|
||||||
|
processLike: { platform: 'linux', env: {} },
|
||||||
|
realpathSync: (p) => p,
|
||||||
|
tunnelBootstrapTtlDefaultMs: 600000,
|
||||||
|
tunnelBootstrapTtlMinMs: 60000,
|
||||||
|
tunnelBootstrapTtlMaxMs: 3600000,
|
||||||
|
tunnelSessionTtlDefaultMs: 86400000,
|
||||||
|
tunnelSessionTtlMinMs: 3600000,
|
||||||
|
tunnelSessionTtlMaxMs: 604800000,
|
||||||
|
});
|
||||||
|
return createSettingsHelpers({
|
||||||
|
normalizePathForPersistence: (value) => value,
|
||||||
|
normalizeDirectoryPath: (value) => value,
|
||||||
|
normalizeTunnelBootstrapTtlMs: (value) => value,
|
||||||
|
normalizeTunnelSessionTtlMs: (value) => value,
|
||||||
|
normalizeTunnelProvider: (value) => value,
|
||||||
|
normalizeTunnelMode: (value) => value,
|
||||||
|
normalizeOptionalPath: (value) => value,
|
||||||
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||||
|
normalizeManagedRemoteTunnelPresets: () => undefined,
|
||||||
|
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
||||||
|
sanitizeTypographySizesPartial: () => undefined,
|
||||||
|
normalizeStringArray: runtime.normalizeStringArray,
|
||||||
|
sanitizeModelRefs: runtime.sanitizeModelRefs,
|
||||||
|
sanitizeSkillCatalogs: () => undefined,
|
||||||
|
sanitizeProjects: () => undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
describe('settings helpers', () => {
|
describe('settings helpers', () => {
|
||||||
it('accepts messageStreamTransport as a persisted shared setting', () => {
|
it('accepts messageStreamTransport as a persisted shared setting', () => {
|
||||||
const helpers = createTestHelpers();
|
const helpers = createTestHelpers();
|
||||||
@@ -188,4 +225,121 @@ describe('settings helpers', () => {
|
|||||||
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('previously-dropped model selector persistence fields', () => {
|
||||||
|
it('round-trips hiddenModels through the sanitizer', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
const input = [
|
||||||
|
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
||||||
|
{ providerID: 'openai', modelID: 'gpt-5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({
|
||||||
|
hiddenModels: input,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty hiddenModels the same way as empty favoriteModels', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
|
||||||
|
const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] });
|
||||||
|
const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] });
|
||||||
|
|
||||||
|
expect(hiddenResult.hiddenModels).toEqual([]);
|
||||||
|
expect(favoriteResult.favoriteModels).toEqual([]);
|
||||||
|
expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips collapsedModelProviders and recentAgents as string arrays', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({
|
||||||
|
collapsedModelProviders: ['anthropic', 'openai'],
|
||||||
|
});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({
|
||||||
|
recentAgents: ['build', 'plan'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips recentEfforts as a Record<string, string[]>', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
const input = {
|
||||||
|
'anthropic/claude-opus-4': ['high', 'default'],
|
||||||
|
'openai/gpt-5': ['low'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({
|
||||||
|
recentEfforts: input,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({});
|
||||||
|
expect(
|
||||||
|
helpers.sanitizeSettingsUpdate({
|
||||||
|
hiddenModels: [
|
||||||
|
{ providerID: 'anthropic' },
|
||||||
|
{ modelID: 'gpt-5' },
|
||||||
|
'not-an-object',
|
||||||
|
null,
|
||||||
|
{ providerID: ' ', modelID: 'x' },
|
||||||
|
{ providerID: 'openai', modelID: '' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toEqual({ hiddenModels: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage collapsedModelProviders and recentAgents input', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage recentEfforts input', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({});
|
||||||
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
|
||||||
|
const helpers = createTestHelpersWithRealSanitizers();
|
||||||
|
const payload = {
|
||||||
|
themeId: 'default',
|
||||||
|
hiddenModels: [
|
||||||
|
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
||||||
|
{ providerID: 'openai', modelID: 'gpt-5' },
|
||||||
|
],
|
||||||
|
collapsedModelProviders: ['anthropic', 'openai'],
|
||||||
|
recentAgents: ['build', 'plan'],
|
||||||
|
recentEfforts: {
|
||||||
|
'anthropic/claude-opus-4': ['high', 'default'],
|
||||||
|
'openai/gpt-5': ['low'],
|
||||||
|
},
|
||||||
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
|
||||||
|
recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitized = helpers.sanitizeSettingsUpdate(payload);
|
||||||
|
|
||||||
|
expect(sanitized.hiddenModels).toEqual(payload.hiddenModels);
|
||||||
|
expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders);
|
||||||
|
expect(sanitized.recentAgents).toEqual(payload.recentAgents);
|
||||||
|
expect(sanitized.recentEfforts).toEqual(payload.recentEfforts);
|
||||||
|
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
||||||
|
expect(sanitized.recentModels).toEqual(payload.recentModels);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user