Files
openchamber/packages/ui/src/lib/persistence.ts
T
achcyano 4296efb64d feat(electron): add Windows startup and system tray support (#2112)
Add Windows launch-at-login with background startup support and extend the
native tray integration to Windows.

Add a Windows-only setting to minimize or close the main window to the
system tray, persist it through desktop settings, and expose it in Settings
search and all locale dictionaries.

Keep tray state synchronized with live sessions on both macOS and Windows,
while preserving the existing macOS behavior.
2026-07-10 12:40:48 +03:00

1482 lines
61 KiB
TypeScript

import type { DesktopSettings } from '@/lib/desktop';
import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore';
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sanitizeStarterRefs } from '@/lib/draftStarters';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') {
return;
}
if (typeof window.__OPENCHAMBER_HOME__ === 'string' && window.__OPENCHAMBER_HOME__.length > 0) {
return;
}
try {
window.__OPENCHAMBER_HOME__ = homeDirectory;
} catch {
/* read-only contextBridge property — leave preload-seeded value */
}
};
const persistToLocalStorage = (settings: DesktopSettings) => {
if (typeof window === 'undefined') {
return;
}
if (settings.themeId) {
localStorage.setItem('selectedThemeId', settings.themeId);
}
if (settings.themeVariant) {
localStorage.setItem('selectedThemeVariant', settings.themeVariant);
}
if (settings.lightThemeId) {
localStorage.setItem('lightThemeId', settings.lightThemeId);
}
if (settings.darkThemeId) {
localStorage.setItem('darkThemeId', settings.darkThemeId);
}
if (typeof settings.useSystemTheme === 'boolean') {
localStorage.setItem('useSystemTheme', String(settings.useSystemTheme));
}
if (settings.lastDirectory) {
localStorage.setItem('lastDirectory', settings.lastDirectory);
}
if (settings.homeDirectory) {
localStorage.setItem('homeDirectory', settings.homeDirectory);
applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
localStorage.setItem('projects', JSON.stringify(settings.projects));
} else {
localStorage.removeItem('projects');
}
if (settings.activeProjectId) {
localStorage.setItem('activeProjectId', settings.activeProjectId);
} else {
localStorage.removeItem('activeProjectId');
}
if (Array.isArray(settings.pinnedDirectories) && settings.pinnedDirectories.length > 0) {
localStorage.setItem('pinnedDirectories', JSON.stringify(settings.pinnedDirectories));
} else {
localStorage.removeItem('pinnedDirectories');
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
const collapsed = settings.projects
.filter((project) => (project as unknown as { sidebarCollapsed?: boolean }).sidebarCollapsed === true)
.map((project) => project.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (collapsed.length > 0) {
localStorage.setItem('oc.sessions.projectCollapse', JSON.stringify(collapsed));
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
}
}
if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
} else {
localStorage.removeItem('gitmojiEnabled');
}
if (typeof settings.directoryShowHidden === 'boolean') {
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
}
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
localStorage.setItem('openInAppId', settings.openInAppId);
}
if (typeof settings.pwaAppName === 'string') {
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
if (normalized.length > 0) {
localStorage.setItem('openchamber.pwaName', normalized);
} else {
localStorage.removeItem('openchamber.pwaName');
}
}
if (typeof settings.mobileKeyboardMode === 'string') {
setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
}
if (typeof settings.openCodeUpdateToastDismissedVersion === 'string') {
const version = settings.openCodeUpdateToastDismissedVersion.trim();
if (version) {
localStorage.setItem('opencode-update-toast-dismissed-version', version);
} else {
localStorage.removeItem('opencode-update-toast-dismissed-version');
}
}
if (typeof settings.dictationEnabled === 'boolean') {
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
}
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
localStorage.setItem('sttProvider', settings.sttProvider);
}
if (typeof settings.sttServerUrl === 'string') {
localStorage.setItem('sttServerUrl', settings.sttServerUrl);
}
if (typeof settings.sttModel === 'string') {
localStorage.setItem('sttModel', settings.sttModel);
}
if (typeof settings.sttLocalModel === 'string') {
localStorage.setItem('sttLocalModel', settings.sttLocalModel);
}
if (typeof settings.sttLanguage === 'string') {
localStorage.setItem('sttLanguage', settings.sttLanguage);
}
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
};
type PersistApi = {
hasHydrated?: () => boolean;
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
};
const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['skillCatalogs']> = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : '';
if (!id || !label || !source) continue;
if (seen.has(id)) continue;
seen.add(id);
result.push({
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
}
return result;
};
const sanitizeShortcutOverrides = (value: unknown): Record<string, string> | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const result: Record<string, string> = {};
for (const [key, combo] of Object.entries(value)) {
const normalizedKey = typeof key === 'string' ? key.trim() : '';
const normalizedCombo = typeof combo === 'string' ? combo.trim() : '';
if (!normalizedKey || !normalizedCombo) continue;
result[normalizedKey] = normalizedCombo;
}
return result;
};
const areStringRecordsEqual = (left: Record<string, string>, right: Record<string, string>): boolean => {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
if (leftEntries.length !== rightEntries.length) return false;
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 => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
};
const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['projects']> = [];
const seenIds = new Set<string>();
const seenPaths = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!rawPath) continue;
const normalizedPath = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalizedPath) continue;
const id = createProjectIdFromPath(normalizedPath);
if (!id) continue;
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
const project: NonNullable<DesktopSettings['projects']>[number] = {
id,
path: normalizedPath,
};
if (typeof candidate.label === 'string' && candidate.label.trim().length > 0) {
project.label = candidate.label.trim();
}
if (typeof candidate.icon === 'string' && candidate.icon.trim().length > 0) {
project.icon = candidate.icon.trim();
}
if (candidate.iconImage === null) {
(project as unknown as Record<string, unknown>).iconImage = null;
} else if (candidate.iconImage && typeof candidate.iconImage === 'object') {
const iconImage = candidate.iconImage as Record<string, unknown>;
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
? Math.max(0, Math.round(iconImage.updatedAt))
: 0;
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
? iconImage.source
: null;
if (mime && updatedAt > 0 && source) {
(project as unknown as Record<string, unknown>).iconImage = { mime, updatedAt, source };
}
}
if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
project.color = candidate.color.trim();
}
if (candidate.iconBackground === null) {
(project as unknown as Record<string, unknown>).iconBackground = null;
} else {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
if (iconBackground) {
(project as unknown as Record<string, unknown>).iconBackground = iconBackground;
}
}
if (typeof candidate.addedAt === 'number' && Number.isFinite(candidate.addedAt) && candidate.addedAt >= 0) {
project.addedAt = candidate.addedAt;
}
if (
typeof candidate.lastOpenedAt === 'number' &&
Number.isFinite(candidate.lastOpenedAt) &&
candidate.lastOpenedAt >= 0
) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
}
result.push(project);
}
return result.length > 0 ? result : undefined;
};
const sanitizeManagedRemoteTunnelPresets = (value: unknown): DesktopSettings['managedRemoteTunnelPresets'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['managedRemoteTunnelPresets']> = [];
const seenIds = new Set<string>();
const seenHostnames = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
const hostname = typeof candidate.hostname === 'string' ? candidate.hostname.trim().toLowerCase() : '';
if (!id || !name || !hostname) continue;
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
seenIds.add(id);
seenHostnames.add(hostname);
result.push({ id, name, hostname });
}
return result;
};
const sanitizeManagedRemoteTunnelPresetTokens = (value: unknown): DesktopSettings['managedRemoteTunnelPresetTokens'] | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const candidate = value as Record<string, unknown>;
const result: Record<string, string> = {};
for (const [key, tokenValue] of Object.entries(candidate)) {
const id = key.trim();
const token = typeof tokenValue === 'string' ? tokenValue.trim() : '';
if (!id || !token) continue;
result[id] = token;
}
return Object.keys(result).length > 0 ? result : undefined;
};
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: Array<{ providerID: string; modelID: string }> = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : '';
const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : '';
if (!providerID || !modelID) continue;
const key = `${providerID}/${modelID}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ providerID, modelID });
if (result.length >= limit) break;
}
return result;
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') {
return candidate;
}
return undefined;
};
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
const store = useUIStore.getState();
const configStore = typeof window !== 'undefined'
? window.__zustand_config_store__?.getState?.() ?? null
: null;
const configStoreApi = typeof window !== 'undefined'
? window.__zustand_config_store__ ?? null
: null;
const queueStore = useMessageQueueStore.getState();
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
store.setShowReasoningTraces(settings.showReasoningTraces);
}
if (typeof settings.sessionRecapEnabled === 'boolean' && settings.sessionRecapEnabled !== store.sessionRecapEnabled) {
store.setSessionRecapEnabled(settings.sessionRecapEnabled);
}
if (typeof settings.sessionSuggestionEnabled === 'boolean' && settings.sessionSuggestionEnabled !== store.sessionSuggestionEnabled) {
store.setSessionSuggestionEnabled(settings.sessionSuggestionEnabled);
}
if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) {
store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks);
}
if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
store.setAutoDeleteEnabled(settings.autoDeleteEnabled);
}
if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) {
const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays));
if (normalized !== store.autoDeleteAfterDays) {
store.setAutoDeleteAfterDays(normalized);
}
}
if (settings.sessionRetentionAction === 'archive' || settings.sessionRetentionAction === 'delete') {
if (settings.sessionRetentionAction !== store.sessionRetentionAction) {
store.setSessionRetentionAction(settings.sessionRetentionAction);
}
}
let nextFollowUpBehavior: FollowUpBehavior | null = null;
if (isFollowUpBehavior(settings.followUpBehavior)) {
nextFollowUpBehavior = settings.followUpBehavior;
} else if (typeof settings.queueModeEnabled === 'boolean') {
nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled);
}
if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) {
queueStore.setFollowUpBehavior(nextFollowUpBehavior);
}
if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
store.setShowDeletionDialog(settings.showDeletionDialog);
}
if (typeof settings.nativeNotificationsEnabled === 'boolean' && settings.nativeNotificationsEnabled !== store.nativeNotificationsEnabled) {
store.setNativeNotificationsEnabled(settings.nativeNotificationsEnabled);
}
if (typeof settings.notificationMode === 'string' && (settings.notificationMode === 'always' || settings.notificationMode === 'hidden-only')) {
if (settings.notificationMode !== store.notificationMode) {
store.setNotificationMode(settings.notificationMode);
}
}
if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) {
store.setNotifyOnSubtasks(settings.notifyOnSubtasks);
}
if (typeof settings.notifyOnCompletion === 'boolean' && settings.notifyOnCompletion !== store.notifyOnCompletion) {
store.setNotifyOnCompletion(settings.notifyOnCompletion);
}
if (typeof settings.notifyOnError === 'boolean' && settings.notifyOnError !== store.notifyOnError) {
store.setNotifyOnError(settings.notifyOnError);
}
if (typeof settings.notifyOnQuestion === 'boolean' && settings.notifyOnQuestion !== store.notifyOnQuestion) {
store.setNotifyOnQuestion(settings.notifyOnQuestion);
}
if (settings.notificationTemplates && typeof settings.notificationTemplates === 'object') {
store.setNotificationTemplates(settings.notificationTemplates);
}
if (typeof settings.summarizeLastMessage === 'boolean' && settings.summarizeLastMessage !== store.summarizeLastMessage) {
store.setSummarizeLastMessage(settings.summarizeLastMessage);
}
if (typeof settings.summaryThreshold === 'number' && Number.isFinite(settings.summaryThreshold)) {
store.setSummaryThreshold(settings.summaryThreshold);
}
if (typeof settings.summaryLength === 'number' && Number.isFinite(settings.summaryLength)) {
store.setSummaryLength(settings.summaryLength);
}
if (typeof settings.maxLastMessageLength === 'number' && Number.isFinite(settings.maxLastMessageLength)) {
store.setMaxLastMessageLength(settings.maxLastMessageLength);
}
if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) {
store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled);
}
if (
typeof settings.showOpenCodeUpdateNotifications === 'boolean'
&& settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications
) {
store.setShowOpenCodeUpdateNotifications(settings.showOpenCodeUpdateNotifications);
}
if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) {
store.setShowToolFileIcons(settings.showToolFileIcons);
}
if (typeof settings.codeBlockLineWrap === 'boolean' && settings.codeBlockLineWrap !== store.codeBlockLineWrap) {
store.setCodeBlockLineWrap(settings.codeBlockLineWrap);
}
if (typeof settings.showTurnChangedFiles === 'boolean' && settings.showTurnChangedFiles !== store.showTurnChangedFiles) {
store.setShowTurnChangedFiles(settings.showTurnChangedFiles);
}
if (typeof settings.showExpandedBashTools === 'boolean' && settings.showExpandedBashTools !== store.showExpandedBashTools) {
store.setShowExpandedBashTools(settings.showExpandedBashTools);
}
if (typeof settings.showExpandedEditTools === 'boolean' && settings.showExpandedEditTools !== store.showExpandedEditTools) {
store.setShowExpandedEditTools(settings.showExpandedEditTools);
}
if (typeof settings.timeFormatPreference === 'string'
&& (settings.timeFormatPreference === 'auto' || settings.timeFormatPreference === '12h' || settings.timeFormatPreference === '24h')) {
if (settings.timeFormatPreference !== store.timeFormatPreference) {
store.setTimeFormatPreference(settings.timeFormatPreference);
}
}
if (typeof settings.weekStartPreference === 'string'
&& (settings.weekStartPreference === 'auto' || settings.weekStartPreference === 'sunday' || settings.weekStartPreference === 'monday')) {
if (settings.weekStartPreference !== store.weekStartPreference) {
store.setWeekStartPreference(settings.weekStartPreference);
}
}
if (typeof settings.chatRenderMode === 'string'
&& (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
if (settings.chatRenderMode !== store.chatRenderMode) {
store.setChatRenderMode(settings.chatRenderMode);
}
}
if (typeof settings.activityRenderMode === 'string'
&& (settings.activityRenderMode === 'collapsed' || settings.activityRenderMode === 'summary')) {
if (settings.activityRenderMode !== store.activityRenderMode) {
store.setActivityRenderMode(settings.activityRenderMode);
}
}
if (typeof settings.mermaidRenderingMode === 'string'
&& (settings.mermaidRenderingMode === 'svg' || settings.mermaidRenderingMode === 'ascii')) {
if (settings.mermaidRenderingMode !== store.mermaidRenderingMode) {
store.setMermaidRenderingMode(settings.mermaidRenderingMode);
}
}
if (typeof settings.userMessageRenderingMode === 'string'
&& (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) {
if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) {
store.setUserMessageRenderingMode(settings.userMessageRenderingMode);
}
}
if (typeof settings.collapsibleUserMessages === 'boolean' && settings.collapsibleUserMessages !== store.collapsibleUserMessages) {
store.setCollapsibleUserMessages(settings.collapsibleUserMessages);
}
if (typeof settings.messageStreamTransport === 'string'
&& (settings.messageStreamTransport === 'auto' || settings.messageStreamTransport === 'ws' || settings.messageStreamTransport === 'sse')) {
if (configStore && settings.messageStreamTransport !== configStore.settingsMessageStreamTransport) {
configStore.setSettingsMessageStreamTransport(settings.messageStreamTransport);
}
}
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
store.setStickyUserHeader(settings.stickyUserHeader);
}
if (typeof settings.expandedEditorToolbar === 'boolean' && settings.expandedEditorToolbar !== store.expandedEditorToolbar) {
store.setExpandedEditorToolbar(settings.expandedEditorToolbar);
}
if (typeof settings.wideChatLayoutEnabled === 'boolean' && settings.wideChatLayoutEnabled !== store.wideChatLayoutEnabled) {
store.setWideChatLayoutEnabled(settings.wideChatLayoutEnabled);
}
if (
typeof settings.showSplitAssistantMessageActions === 'boolean'
&& settings.showSplitAssistantMessageActions !== store.showSplitAssistantMessageActions
) {
store.setShowSplitAssistantMessageActions(settings.showSplitAssistantMessageActions);
}
if (typeof settings.reportUsage === 'boolean' && settings.reportUsage !== store.reportUsage) {
store.setReportUsage(settings.reportUsage);
}
if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
store.setFontSize(settings.fontSize);
}
if (Array.isArray(settings.draftStarters)) {
const nextStarters = sanitizeStarterRefs(settings.draftStarters);
if (JSON.stringify(store.globalDraftStarters) !== JSON.stringify(nextStarters)) {
store.setGlobalDraftStarters(nextStarters);
}
}
if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) {
store.setTerminalFontSize(settings.terminalFontSize);
}
if (isUiFontOption(settings.uiFont) && settings.uiFont !== store.uiFont) {
store.setUiFont(settings.uiFont);
}
if (isMonoFontOption(settings.monoFont) && settings.monoFont !== store.monoFont) {
store.setMonoFont(settings.monoFont);
}
if (typeof settings.padding === 'number' && Number.isFinite(settings.padding) && settings.padding !== store.padding) {
store.setPadding(settings.padding);
}
if (typeof settings.cornerRadius === 'number' && Number.isFinite(settings.cornerRadius) && settings.cornerRadius !== store.cornerRadius) {
store.setCornerRadius(settings.cornerRadius);
}
if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) {
store.setInputBarOffset(settings.inputBarOffset);
}
if (settings.shortcutOverrides && !areStringRecordsEqual(settings.shortcutOverrides, store.shortcutOverrides)) {
useUIStore.setState({ shortcutOverrides: settings.shortcutOverrides });
}
if (typeof settings.mobileKeyboardMode === 'string') {
const mode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode, store.mobileKeyboardMode);
if (mode !== store.mobileKeyboardMode) {
store.setMobileKeyboardMode(mode);
}
}
if (configStoreApi && configStore) {
const nextConfigState: Partial<typeof configStore> = {};
if (typeof settings.dictationEnabled === 'boolean' && settings.dictationEnabled !== configStore.dictationEnabled) {
nextConfigState.dictationEnabled = settings.dictationEnabled;
}
if ((settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') && settings.sttProvider !== configStore.sttProvider) {
nextConfigState.sttProvider = settings.sttProvider;
}
if (typeof settings.sttServerUrl === 'string' && settings.sttServerUrl !== configStore.sttServerUrl) {
nextConfigState.sttServerUrl = settings.sttServerUrl;
}
if (typeof settings.sttModel === 'string' && settings.sttModel !== configStore.sttModel) {
nextConfigState.sttModel = settings.sttModel;
}
if (typeof settings.sttLocalModel === 'string' && settings.sttLocalModel !== configStore.sttLocalModel) {
nextConfigState.sttLocalModel = settings.sttLocalModel;
}
if (typeof settings.sttLanguage === 'string' && settings.sttLanguage !== configStore.sttLanguage) {
nextConfigState.sttLanguage = settings.sttLanguage;
}
if (Object.keys(nextConfigState).length > 0) {
configStoreApi.setState(nextConfigState);
}
}
if (Array.isArray(settings.favoriteModels)) {
const current = store.favoriteModels;
const next = settings.favoriteModels;
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;
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) {
store.setDiffLayoutPreference(settings.diffLayoutPreference);
}
}
if (typeof settings.gitChangesViewMode === 'string'
&& (settings.gitChangesViewMode === 'flat' || settings.gitChangesViewMode === 'tree')) {
if (settings.gitChangesViewMode !== store.gitChangesViewMode) {
store.setGitChangesViewMode(settings.gitChangesViewMode);
}
}
if (typeof settings.directoryShowHidden === 'boolean') {
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
setFilesViewShowGitignored(settings.filesViewShowGitignored, { persist: false });
}
};
const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Record<string, unknown>;
const result: DesktopSettings = {};
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
result.themeId = candidate.themeId;
}
if (candidate.useSystemTheme === true || candidate.useSystemTheme === false) {
result.useSystemTheme = candidate.useSystemTheme;
}
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
result.themeVariant = candidate.themeVariant;
}
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
result.lightThemeId = candidate.lightThemeId;
}
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
result.darkThemeId = candidate.darkThemeId;
}
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
result.lastDirectory = candidate.lastDirectory;
}
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
result.homeDirectory = candidate.homeDirectory;
}
if (typeof candidate.opencodeBinary === 'string') {
const trimmed = candidate.opencodeBinary.trim();
result.opencodeBinary = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
}
if (typeof candidate.desktopKeepAwakeEnabled === 'boolean') {
result.desktopKeepAwakeEnabled = candidate.desktopKeepAwakeEnabled;
}
if (typeof candidate.desktopMinimizeToTrayEnabled === 'boolean') {
result.desktopMinimizeToTrayEnabled = candidate.desktopMinimizeToTrayEnabled;
}
const projects = sanitizeProjects(candidate.projects);
if (projects) {
result.projects = projects;
}
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = candidate.securityScopedBookmarks.filter(
(entry): entry is string => typeof entry === 'string' && entry.length > 0
);
}
if (Array.isArray(candidate.pinnedDirectories)) {
result.pinnedDirectories = Array.from(
new Set(
candidate.pinnedDirectories.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
)
);
}
if (Array.isArray(candidate.draftStarters)) {
result.draftStarters = sanitizeStarterRefs(candidate.draftStarters);
}
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
if (typeof candidate.sessionSuggestionEnabled === 'boolean') {
result.sessionSuggestionEnabled = candidate.sessionSuggestionEnabled;
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
}
if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') {
result.sessionRetentionAction = candidate.sessionRetentionAction;
}
if (typeof candidate.tunnelProvider === 'string') {
const provider = candidate.tunnelProvider.trim().toLowerCase();
if (provider.length > 0) {
result.tunnelProvider = provider;
}
}
if (typeof candidate.tunnelMode === 'string') {
const mode = candidate.tunnelMode.trim().toLowerCase();
if (mode === 'quick' || mode === 'managed-remote' || mode === 'managed-local') {
result.tunnelMode = mode;
}
}
if (candidate.tunnelBootstrapTtlMs === null) {
result.tunnelBootstrapTtlMs = null;
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
result.tunnelBootstrapTtlMs = candidate.tunnelBootstrapTtlMs;
}
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
result.tunnelSessionTtlMs = candidate.tunnelSessionTtlMs;
}
if (candidate.managedLocalTunnelConfigPath === null) {
result.managedLocalTunnelConfigPath = null;
} else if (typeof candidate.managedLocalTunnelConfigPath === 'string') {
const trimmed = candidate.managedLocalTunnelConfigPath.trim();
result.managedLocalTunnelConfigPath = trimmed.length > 0 ? trimmed : null;
}
if (typeof candidate.managedRemoteTunnelHostname === 'string') {
result.managedRemoteTunnelHostname = candidate.managedRemoteTunnelHostname.trim();
}
if (candidate.managedRemoteTunnelToken === null) {
result.managedRemoteTunnelToken = null;
} else if (typeof candidate.managedRemoteTunnelToken === 'string') {
result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim();
}
const managedRemoteTunnelPresets = sanitizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets);
if (managedRemoteTunnelPresets) {
result.managedRemoteTunnelPresets = managedRemoteTunnelPresets;
}
if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') {
const trimmed = candidate.managedRemoteTunnelSelectedPresetId.trim();
result.managedRemoteTunnelSelectedPresetId = trimmed.length > 0 ? trimmed : undefined;
}
const managedRemoteTunnelPresetTokens = sanitizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens);
if (managedRemoteTunnelPresetTokens) {
result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens;
}
if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
result.defaultModel = candidate.defaultModel;
}
if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.length > 0) {
result.defaultVariant = candidate.defaultVariant;
}
if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
result.defaultAgent = candidate.defaultAgent;
}
if (typeof candidate.smallModelUseDefault === 'boolean') {
result.smallModelUseDefault = candidate.smallModelUseDefault;
}
if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
result.smallModelOverride = candidate.smallModelOverride;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled;
}
if (isFollowUpBehavior(candidate.followUpBehavior)) {
result.followUpBehavior = candidate.followUpBehavior;
} else if (typeof candidate.queueModeEnabled === 'boolean') {
result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled);
}
if (typeof candidate.showDeletionDialog === 'boolean') {
result.showDeletionDialog = candidate.showDeletionDialog;
}
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
}
if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) {
result.notificationMode = candidate.notificationMode;
}
if (typeof candidate.notifyOnSubtasks === 'boolean') {
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
}
if (typeof candidate.notifyOnCompletion === 'boolean') {
result.notifyOnCompletion = candidate.notifyOnCompletion;
}
if (typeof candidate.notifyOnError === 'boolean') {
result.notifyOnError = candidate.notifyOnError;
}
if (typeof candidate.notifyOnQuestion === 'boolean') {
result.notifyOnQuestion = candidate.notifyOnQuestion;
}
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
const templates = candidate.notificationTemplates as Record<string, unknown>;
const validateTemplate = (key: string): { title: string; message: string } | undefined => {
const value = templates[key];
if (!value || typeof value !== 'object') return undefined;
const obj = value as Record<string, unknown>;
const title = typeof obj.title === 'string' ? obj.title : '';
const message = typeof obj.message === 'string' ? obj.message : '';
return { title, message };
};
const completion = validateTemplate('completion');
const error = validateTemplate('error');
const question = validateTemplate('question');
const subtask = validateTemplate('subtask');
if (completion || error || question || subtask) {
result.notificationTemplates = {
completion: completion ?? { title: 'Task Complete', message: 'Your task has finished.' },
error: error ?? { title: 'Error Occurred', message: 'An error occurred while processing your task.' },
question: question ?? { title: 'Input Needed', message: 'Please provide input to continue.' },
subtask: subtask ?? { title: 'Subtask Complete', message: 'A subtask has finished.' },
};
}
}
if (typeof candidate.summarizeLastMessage === 'boolean') {
result.summarizeLastMessage = candidate.summarizeLastMessage;
}
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
}
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
}
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
}
if (typeof candidate.usageAutoRefresh === 'boolean') {
result.usageAutoRefresh = candidate.usageAutoRefresh;
}
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
result.usageRefreshIntervalMs = candidate.usageRefreshIntervalMs;
}
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
result.usageDisplayMode = candidate.usageDisplayMode;
}
if (typeof candidate.usageShowPredValues === 'boolean') {
result.usageShowPredValues = candidate.usageShowPredValues;
}
if (Array.isArray(candidate.usageDropdownProviders)) {
result.usageDropdownProviders = candidate.usageDropdownProviders.filter(
(entry): entry is string => typeof entry === 'string' && entry.length > 0
);
}
// Parse usageSelectedModels (Record<string, string[]>)
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
const selectedModels: Record<string, string[]> = {};
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
if (Array.isArray(models)) {
selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string');
}
}
if (Object.keys(selectedModels).length > 0) {
result.usageSelectedModels = selectedModels;
}
}
// Parse usageCollapsedFamilies (Record<string, string[]>)
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
const collapsedFamilies: Record<string, string[]> = {};
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
if (Array.isArray(families)) {
collapsedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
if (Object.keys(collapsedFamilies).length > 0) {
result.usageCollapsedFamilies = collapsedFamilies;
}
}
// Parse usageExpandedFamilies (Record<string, string[]>) - inverted collapsed logic for header dropdown
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
const expandedFamilies: Record<string, string[]> = {};
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
if (Array.isArray(families)) {
expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
if (Object.keys(expandedFamilies).length > 0) {
result.usageExpandedFamilies = expandedFamilies;
}
}
// Parse usageModelGroups - custom model groups configuration per provider
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
const modelGroups: Record<string, {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>;
renamedGroups?: Record<string, string>;
}> = {};
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
if (config && typeof config === 'object') {
const typedConfig = config as Record<string, unknown>;
const providerConfig: {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>;
renamedGroups?: Record<string, string>;
} = {};
// Parse customGroups
if (Array.isArray(typedConfig.customGroups)) {
providerConfig.customGroups = typedConfig.customGroups
.filter((g): g is Record<string, unknown> => g && typeof g === 'object')
.map((g) => ({
id: String(g.id ?? ''),
label: String(g.label ?? ''),
models: Array.isArray(g.models)
? g.models.filter((m): m is string => typeof m === 'string')
: [],
order: typeof g.order === 'number' ? g.order : 0,
}));
}
// Parse modelAssignments
if (typedConfig.modelAssignments && typeof typedConfig.modelAssignments === 'object') {
providerConfig.modelAssignments = Object.fromEntries(
Object.entries(typedConfig.modelAssignments as Record<string, unknown>)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, String(v)])
);
}
// Parse renamedGroups
if (typedConfig.renamedGroups && typeof typedConfig.renamedGroups === 'object') {
providerConfig.renamedGroups = Object.fromEntries(
Object.entries(typedConfig.renamedGroups as Record<string, unknown>)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, String(v)])
);
}
if (Object.keys(providerConfig).length > 0) {
modelGroups[providerId] = providerConfig;
}
}
}
if (Object.keys(modelGroups).length > 0) {
result.usageModelGroups = modelGroups;
}
}
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
}
if (typeof candidate.openCodeUpdateToastDismissedVersion === 'string') {
result.openCodeUpdateToastDismissedVersion = candidate.openCodeUpdateToastDismissedVersion.trim().slice(0, 128);
}
if (typeof candidate.showToolFileIcons === 'boolean') {
result.showToolFileIcons = candidate.showToolFileIcons;
}
if (typeof candidate.codeBlockLineWrap === 'boolean') {
result.codeBlockLineWrap = candidate.codeBlockLineWrap;
}
if (typeof candidate.showTurnChangedFiles === 'boolean') {
result.showTurnChangedFiles = candidate.showTurnChangedFiles;
}
if (typeof candidate.showExpandedBashTools === 'boolean') {
result.showExpandedBashTools = candidate.showExpandedBashTools;
}
if (typeof candidate.showExpandedEditTools === 'boolean') {
result.showExpandedEditTools = candidate.showExpandedEditTools;
}
if (typeof candidate.timeFormatPreference === 'string'
&& (candidate.timeFormatPreference === 'auto' || candidate.timeFormatPreference === '12h' || candidate.timeFormatPreference === '24h')) {
result.timeFormatPreference = candidate.timeFormatPreference;
}
if (typeof candidate.weekStartPreference === 'string'
&& (candidate.weekStartPreference === 'auto' || candidate.weekStartPreference === 'sunday' || candidate.weekStartPreference === 'monday')) {
result.weekStartPreference = candidate.weekStartPreference;
}
if (typeof candidate.chatRenderMode === 'string'
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
result.chatRenderMode = candidate.chatRenderMode;
}
if (typeof candidate.messageStreamTransport === 'string'
&& (candidate.messageStreamTransport === 'auto' || candidate.messageStreamTransport === 'ws' || candidate.messageStreamTransport === 'sse')) {
result.messageStreamTransport = candidate.messageStreamTransport;
}
if (typeof candidate.activityRenderMode === 'string'
&& (candidate.activityRenderMode === 'collapsed' || candidate.activityRenderMode === 'summary')) {
result.activityRenderMode = candidate.activityRenderMode;
}
if (typeof candidate.mermaidRenderingMode === 'string'
&& (candidate.mermaidRenderingMode === 'svg' || candidate.mermaidRenderingMode === 'ascii')) {
result.mermaidRenderingMode = candidate.mermaidRenderingMode;
}
if (typeof candidate.userMessageRenderingMode === 'string'
&& (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) {
result.userMessageRenderingMode = candidate.userMessageRenderingMode;
}
if (typeof candidate.collapsibleUserMessages === 'boolean') {
result.collapsibleUserMessages = candidate.collapsibleUserMessages;
}
if (typeof candidate.stickyUserHeader === 'boolean') {
result.stickyUserHeader = candidate.stickyUserHeader;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
}
if (typeof candidate.showSplitAssistantMessageActions === 'boolean') {
result.showSplitAssistantMessageActions = candidate.showSplitAssistantMessageActions;
}
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
result.fontSize = candidate.fontSize;
}
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
result.terminalFontSize = candidate.terminalFontSize;
}
if (isUiFontOption(candidate.uiFont)) {
result.uiFont = candidate.uiFont;
}
if (isMonoFontOption(candidate.monoFont)) {
result.monoFont = candidate.monoFont;
}
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
result.padding = candidate.padding;
}
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
result.cornerRadius = candidate.cornerRadius;
}
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = candidate.inputBarOffset;
}
const shortcutOverrides = sanitizeShortcutOverrides(candidate.shortcutOverrides);
if (shortcutOverrides) {
result.shortcutOverrides = shortcutOverrides;
}
if (typeof candidate.mobileKeyboardMode === 'string') {
if (candidate.mobileKeyboardMode === 'native' || candidate.mobileKeyboardMode === 'resize-content') {
result.mobileKeyboardMode = candidate.mobileKeyboardMode;
}
}
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
if (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);
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'
|| candidate.diffLayoutPreference === 'inline'
|| candidate.diffLayoutPreference === 'side-by-side')
) {
result.diffLayoutPreference = candidate.diffLayoutPreference;
}
if (
typeof candidate.gitChangesViewMode === 'string'
&& (candidate.gitChangesViewMode === 'flat' || candidate.gitChangesViewMode === 'tree')
) {
result.gitChangesViewMode = candidate.gitChangesViewMode;
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
}
if (typeof candidate.filesViewShowGitignored === 'boolean') {
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
}
if (typeof candidate.openInAppId === 'string' && candidate.openInAppId.length > 0) {
result.openInAppId = candidate.openInAppId;
}
if (typeof candidate.pwaAppName === 'string') {
const normalized = candidate.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
result.pwaAppName = normalized.length > 0 ? normalized : '';
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
}
if (typeof candidate.reportUsage === 'boolean') {
result.reportUsage = candidate.reportUsage;
}
if (typeof candidate.globalBehaviorPrompt === 'string') {
result.globalBehaviorPrompt = candidate.globalBehaviorPrompt;
}
if (typeof candidate.responseStyleEnabled === 'boolean') {
result.responseStyleEnabled = candidate.responseStyleEnabled;
}
if (
typeof candidate.responseStylePreset === 'string'
&& (candidate.responseStylePreset === 'concise'
|| candidate.responseStylePreset === 'detailed'
|| candidate.responseStylePreset === 'mentor'
|| candidate.responseStylePreset === 'pushback'
|| candidate.responseStylePreset === 'noFiller'
|| candidate.responseStylePreset === 'matchEnergy'
|| candidate.responseStylePreset === 'warmPeer'
|| candidate.responseStylePreset === 'custom')
) {
result.responseStylePreset = candidate.responseStylePreset;
}
if (typeof candidate.responseStyleCustomInstructions === 'string') {
result.responseStyleCustomInstructions = candidate.responseStyleCustomInstructions;
}
if (typeof candidate.dictationEnabled === 'boolean') {
result.dictationEnabled = candidate.dictationEnabled;
}
if (candidate.sttProvider === 'local' || candidate.sttProvider === 'openai-compatible') {
result.sttProvider = candidate.sttProvider;
} else if (candidate.sttProvider === 'server') {
// Legacy provider migration: 'server' was the OpenAI-compatible endpoint.
result.sttProvider = 'openai-compatible';
} else if (candidate.sttProvider === 'browser' || candidate.sttProvider === 'wasm') {
result.sttProvider = 'local';
}
if (typeof candidate.sttServerUrl === 'string') {
result.sttServerUrl = candidate.sttServerUrl.trim();
}
if (typeof candidate.sttModel === 'string') {
result.sttModel = candidate.sttModel.trim();
}
if (typeof candidate.sttLocalModel === 'string') {
result.sttLocalModel = candidate.sttLocalModel.trim();
}
if (typeof candidate.sttLanguage === 'string') {
result.sttLanguage = candidate.sttLanguage.trim();
}
return result;
};
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsCache: { value: DesktopSettings | null; at: number } | null = null;
let _settingsInflight: Promise<DesktopSettings | null> | null = null;
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
const fetchWebSettings = async (): Promise<DesktopSettings | null> => {
// Return cached if fresh
if (_settingsCache && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) {
return _settingsCache.value;
}
// Dedup concurrent calls
if (_settingsInflight) return _settingsInflight;
_settingsInflight = (async (): Promise<DesktopSettings | null> => {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = sanitizeWebSettings(result.settings);
_settingsCache = { value: settings, at: Date.now() };
return settings;
} catch (error) {
console.warn('Failed to load shared settings from runtime settings API:', error);
}
}
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
const settings = sanitizeWebSettings(data);
_settingsCache = { value: settings, at: Date.now() };
return settings;
} catch (error) {
console.warn('Failed to load shared settings from server:', error);
return null;
}
})().finally(() => { _settingsInflight = null; });
return _settingsInflight;
};
/** Invalidate cached settings (call after a successful PUT) */
export const invalidateSettingsCache = (): void => {
_settingsCache = null;
};
export const syncDesktopSettings = async (): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
const persistApi = getPersistApi();
// Wait for Zustand persist hydration before applying server settings.
// Otherwise `set()`-calls race with hydration: we set X, then hydration
// reads localStorage and overwrites back to the persisted value.
const waitForHydration = (): Promise<void> => {
if (!persistApi?.hasHydrated || persistApi.hasHydrated()) {
return Promise.resolve();
}
if (!persistApi.onFinishHydration) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
const unsubscribe = persistApi.onFinishHydration!(() => {
unsubscribe?.();
finish();
});
// Guard: hydration may have flipped to true between the hasHydrated
// check and the onFinishHydration subscription — resolve immediately.
if (persistApi.hasHydrated?.()) finish();
});
};
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
// a TypeError from writing to a contextBridge-protected global) doesn't
// prevent server settings from reaching the Zustand store.
const applySettings = async (settings: DesktopSettings) => {
try {
persistToLocalStorage(settings);
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
try {
applyDesktopUiPreferences(settings);
} catch (error) {
console.warn('applyDesktopUiPreferences failed:', error);
}
dispatchSettingsSynced(settings);
};
try {
const webSettings = await fetchWebSettings();
if (webSettings) {
await applySettings(webSettings);
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
}
};
// Coalesce rapid updateDesktopSettings calls into a single PUT
let _pendingSettingsChanges: Partial<DesktopSettings> | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
const SETTINGS_DEBOUNCE_MS = 200;
const _flushSettingsUpdate = async (): Promise<void> => {
const changes = _pendingSettingsChanges;
const waiters = _settingsFlushWaiters;
_pendingSettingsChanges = null;
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
if (!changes || Object.keys(changes).length === 0) {
waiters.forEach((resolve) => resolve());
return;
}
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const updated = await runtimeSettings.save(changes);
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
_settingsCache = null;
}
waiters.forEach((resolve) => resolve());
return;
} catch (error) {
console.warn('Failed to update settings via runtime settings API:', error);
}
}
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
return;
}
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
}
} catch (error) {
console.warn('Failed to update shared settings via API:', error);
} finally {
waiters.forEach((resolve) => resolve());
}
};
export const updateDesktopSettings = async (changes: Partial<DesktopSettings>): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
if (_settingsFlushTimer) {
clearTimeout(_settingsFlushTimer);
}
const flushed = new Promise<void>((resolve) => {
_settingsFlushWaiters.push(resolve);
});
_settingsFlushTimer = setTimeout(() => void _flushSettingsUpdate(), SETTINGS_DEBOUNCE_MS);
return flushed;
};
export const initializeAppearancePreferences = async (): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
const persistApi = getPersistApi();
try {
const appearance = await loadAppearancePreferences();
if (!appearance) {
return;
}
const applyAppearance = () => applyAppearancePreferences(appearance);
if (persistApi?.hasHydrated?.()) {
applyAppearance();
return;
}
applyAppearance();
if (persistApi?.onFinishHydration) {
const unsubscribe = persistApi.onFinishHydration(() => {
unsubscribe?.();
applyAppearance();
});
}
} catch (error) {
console.warn('Failed to load appearance preferences:', error);
}
};