Files
openchamber/packages/ui/src/lib/persistence.ts
T
Bohdan Triapitsyn 34e8a24b20 feat(knowledge): rebuild the project notes panel as Project knowledge (#2973)
The panel stored notes, todos and plans inside one shared JSON file that
six unrelated domains also wrote to, synchronised itself through window
CustomEvents, and could only read plans. It is now Project knowledge:
server-owned storage with explicit routes, a store with rollback, a
section sidebar, plans that open and edit in place, and search across
all of it.

Notes and plans the user pins travel with every message sent in that
project. Pinning is project state, not an attachment to one message, so
it holds until unpinned and the work status panel names what is riding
along and can detach it.

Agent memory is added alongside, in two scopes: what is true about the
user, and what is true about this codebase. The split is not cosmetic —
a wrong project fact costs one project and is noticed, while a wrong
global fact quietly shapes every session everywhere and the user has no
code to check it against. It stays separate from notes so an agent
mistake cannot land in what the user wrote. Sessions receive an index of
titles only; bodies are read on demand, because an index carrying full
text grows until it crowds out the conversation.

Deciding what a session must be told, and whether it has been told, now
lives on the server. The client owned it before, which meant sessions
started without a UI — scheduled tasks, sessions the agent dispatches —
received nothing at all, and a tab's record of what it had sent outlived
the conversation: after compaction the agent no longer held the block
while the tab went on believing it did. What was delivered is recorded
in the session's own metadata, and compaction restores it through the
runtime that already restores pinned messages, in the same turn.

Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there
is no tool, no routes, no session index, no settings row and no panel
tab. Absent rather than switched off, so nothing invites turning on a
feature that has not been announced. Pinned notes and plans are
unaffected and ship as normal.
2026-08-18 02:59:04 +03:00

1971 lines
84 KiB
TypeScript

import type { DesktopSettings } from '@/lib/desktop';
import { sanitizeWorkStatusHiddenSections } from '@/components/chat/work-status/sections';
import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore';
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
import {
DEFAULT_FOLLOW_UP_BEHAVIOR,
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';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
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 SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index';
const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:';
const MAX_SETTINGS_MIRROR_RUNTIMES = 5;
export const getRuntimeSettingsMirrorStorageKey = (runtimeKey: string): string =>
`${SETTINGS_MIRROR_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
const setOrRemoveLocalStorage = (key: string, value: string | null): void => {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
};
const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => {
const mirror = {
themeId: settings.themeId,
themeVariant: settings.themeVariant,
lightThemeId: settings.lightThemeId,
darkThemeId: settings.darkThemeId,
useSystemTheme: settings.useSystemTheme,
lastDirectory: settings.lastDirectory,
homeDirectory: settings.homeDirectory,
projects: settings.projects,
activeProjectId: settings.activeProjectId,
pinnedDirectories: settings.pinnedDirectories,
gitmojiEnabled: settings.gitmojiEnabled,
directoryShowHidden: settings.directoryShowHidden,
filesViewShowGitignored: settings.filesViewShowGitignored,
openInAppId: settings.openInAppId,
pwaAppName: settings.pwaAppName,
mobileKeyboardMode: settings.mobileKeyboardMode,
openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion,
dictationEnabled: settings.dictationEnabled,
sttProvider: settings.sttProvider,
sttServerUrl: settings.sttServerUrl,
sttModel: settings.sttModel,
sttLocalModel: settings.sttLocalModel,
sttLanguage: settings.sttLanguage,
};
localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror));
let previous: string[] = [];
try {
const parsed = JSON.parse(localStorage.getItem(SETTINGS_MIRROR_INDEX_KEY) ?? '[]') as unknown;
if (Array.isArray(parsed)) previous = parsed.filter((entry): entry is string => typeof entry === 'string');
} catch {
previous = [];
}
const runtimes = [runtimeKey, ...previous.filter((entry) => entry !== runtimeKey)].slice(0, MAX_SETTINGS_MIRROR_RUNTIMES);
for (const staleRuntime of previous) {
if (!runtimes.includes(staleRuntime)) localStorage.removeItem(getRuntimeSettingsMirrorStorageKey(staleRuntime));
}
localStorage.setItem(SETTINGS_MIRROR_INDEX_KEY, JSON.stringify(runtimes));
};
const persistToLocalStorage = (settings: DesktopSettings) => {
if (typeof window === 'undefined') {
return;
}
persistRuntimeSettingsMirror(settings, getRuntimeKey());
setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
if (settings.homeDirectory) {
localStorage.setItem('homeDirectory', settings.homeDirectory);
applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
} else {
localStorage.removeItem('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.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');
}
} 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');
} else {
localStorage.removeItem('directoryTreeShowHidden');
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
} else {
localStorage.removeItem('filesViewShowGitignored');
}
setOrRemoveLocalStorage('openInAppId', typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0 ? settings.openInAppId : null);
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');
}
} else {
localStorage.removeItem('openchamber.pwaName');
}
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');
}
} else {
localStorage.removeItem('opencode-update-toast-dismissed-version');
}
if (typeof settings.dictationEnabled === 'boolean') {
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
} else {
localStorage.removeItem('dictationEnabled');
}
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
localStorage.setItem('sttProvider', settings.sttProvider);
} else {
localStorage.removeItem('sttProvider');
}
setOrRemoveLocalStorage('sttServerUrl', typeof settings.sttServerUrl === 'string' ? settings.sttServerUrl : null);
setOrRemoveLocalStorage('sttModel', typeof settings.sttModel === 'string' ? settings.sttModel : null);
setOrRemoveLocalStorage('sttLocalModel', typeof settings.sttLocalModel === 'string' ? settings.sttLocalModel : null);
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
};
type SettingsSaveState = 'idle' | 'saving' | 'error';
let _settingsSaveState: SettingsSaveState = 'idle';
let _settingsSaveStateResetTimer: ReturnType<typeof setTimeout> | null = null;
const _settingsSaveStateListeners = new Set<() => void>();
export const getSettingsSaveState = (): SettingsSaveState => _settingsSaveState;
export const subscribeToSettingsSaveState = (listener: () => void): (() => void) => {
_settingsSaveStateListeners.add(listener);
return () => _settingsSaveStateListeners.delete(listener);
};
/**
* Drive the shared settings save indicator from pages that persist through
* their own APIs instead of updateDesktopSettings. 'error' resets to idle.
*/
export const reportSettingsSaveState = (state: 'saving' | 'saved' | 'error'): void => {
dispatchSettingsSaveState(state);
};
const dispatchSettingsSaveState = (state: 'saving' | 'saved' | 'error'): void => {
if (_settingsSaveStateResetTimer) {
clearTimeout(_settingsSaveStateResetTimer);
_settingsSaveStateResetTimer = null;
}
// Quiet indicator: success is the normal case and renders nothing ('saved' → idle);
// only in-flight saves and failures surface in the UI.
const nextState: SettingsSaveState = state === 'saved' ? 'idle' : state;
if (nextState !== _settingsSaveState) {
_settingsSaveState = nextState;
_settingsSaveStateListeners.forEach((listener) => listener());
}
if (nextState === 'error') {
_settingsSaveStateResetTimer = setTimeout(() => dispatchSettingsSaveState('saved'), 6000);
}
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new CustomEvent<'saving' | 'saved' | 'error'>('openchamber:settings-save-state', { detail: state }));
};
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);
const catalog: NonNullable<DesktopSettings['skillCatalogs']>[number] = {
id,
label,
source,
};
if (subpath) catalog.subpath = subpath;
if (gitIdentityId) catalog.gitIdentityId = gitIdentityId;
result.push(catalog);
}
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.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.iconImage = { mime, updatedAt, source };
}
}
if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
project.color = candidate.color.trim();
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
} else {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
if (iconBackground) {
project.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.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.persist;
if (candidate && typeof candidate === 'object') {
return candidate;
}
return undefined;
};
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => {
const defaults = useUIStore.getInitialState();
return {
useSystemTheme: true,
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
workStatusHiddenSections: defaults.workStatusHiddenSections,
sessionRecapEnabled: defaults.sessionRecapEnabled,
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
sessionGoalEnabled: defaults.sessionGoalEnabled,
sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled,
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
autoDeleteEnabled: defaults.autoDeleteEnabled,
autoSaveEnabled: defaults.autoSaveEnabled,
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
sessionRetentionAction: defaults.sessionRetentionAction,
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
showDeletionDialog: defaults.showDeletionDialog,
nativeNotificationsEnabled: defaults.nativeNotificationsEnabled,
notificationMode: defaults.notificationMode,
notifyOnSubtasks: defaults.notifyOnSubtasks,
notifyOnCompletion: defaults.notifyOnCompletion,
notifyOnError: defaults.notifyOnError,
notifyOnQuestion: defaults.notifyOnQuestion,
notificationTemplates: defaults.notificationTemplates,
summarizeLastMessage: defaults.summarizeLastMessage,
summaryThreshold: defaults.summaryThreshold,
summaryLength: defaults.summaryLength,
maxLastMessageLength: defaults.maxLastMessageLength,
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
agentControlToolEnabled: defaults.agentControlToolEnabled,
agentWebToolEnabled: defaults.agentWebToolEnabled,
agentMemoryToolEnabled: defaults.agentMemoryToolEnabled,
showToolFileIcons: defaults.showToolFileIcons,
codeBlockLineWrap: defaults.codeBlockLineWrap,
showTurnChangedFiles: defaults.showTurnChangedFiles,
showExpandedBashTools: defaults.showExpandedBashTools,
showExpandedEditTools: defaults.showExpandedEditTools,
timeFormatPreference: defaults.timeFormatPreference,
weekStartPreference: defaults.weekStartPreference,
desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
desktopWindowControlsStyle: defaults.desktopWindowControlsStyle,
chatRenderMode: defaults.chatRenderMode,
activityRenderMode: defaults.activityRenderMode,
mermaidRenderingMode: defaults.mermaidRenderingMode,
userMessageRenderingMode: defaults.userMessageRenderingMode,
collapsibleUserMessages: defaults.collapsibleUserMessages,
messageStreamTransport: 'auto',
stickyUserHeader: defaults.stickyUserHeader,
promptNavigatorEnabled: defaults.promptNavigatorEnabled,
expandedEditorToolbar: defaults.expandedEditorToolbar,
wideChatLayoutEnabled: defaults.wideChatLayoutEnabled,
showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions,
draftStartersVisible: defaults.draftStartersVisible,
reportUsage: defaults.reportUsage,
fontSize: defaults.fontSize,
terminalFontSize: defaults.terminalFontSize,
terminalShell: defaults.terminalShell,
terminalLoginShells: defaults.terminalLoginShells,
editorFontSize: defaults.editorFontSize,
uiFont: defaults.uiFont,
monoFont: defaults.monoFont,
padding: defaults.padding,
cornerRadius: defaults.cornerRadius,
inputBarOffset: defaults.inputBarOffset,
shortcutOverrides: defaults.shortcutOverrides,
mobileKeyboardMode: 'resize-content',
favoriteModels: defaults.favoriteModels,
hiddenModels: defaults.hiddenModels,
collapsedModelProviders: defaults.collapsedModelProviders,
recentModels: defaults.recentModels,
recentAgents: defaults.recentAgents,
recentEfforts: defaults.recentEfforts,
diffLayoutPreference: defaults.diffLayoutPreference,
gitChangesViewMode: defaults.gitChangesViewMode,
directoryShowHidden: true,
filesViewShowGitignored: false,
dictationEnabled: true,
sttProvider: 'local',
sttServerUrl: 'http://localhost:8001/v1',
sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
sttLocalModel: 'parakeet-tdt-0.6b-v2-int8',
sttLanguage: '',
...settings,
};
};
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.workStatusPanelEnabled === 'boolean'
&& settings.workStatusPanelEnabled !== store.workStatusPanelEnabled) {
store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled);
}
if (Array.isArray(settings.workStatusHiddenSections)) {
const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections);
if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000')) {
store.setWorkStatusHiddenSections(next);
}
}
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.sessionGoalEnabled === 'boolean' && settings.sessionGoalEnabled !== store.sessionGoalEnabled) {
store.setSessionGoalEnabled(settings.sessionGoalEnabled);
}
if (typeof settings.sessionGoalDefaultBudgetEnabled === 'boolean' && settings.sessionGoalDefaultBudgetEnabled !== store.sessionGoalDefaultBudgetEnabled) {
store.setSessionGoalDefaultBudgetEnabled(settings.sessionGoalDefaultBudgetEnabled);
}
if (typeof settings.sessionGoalDefaultBudget === 'number' && Number.isFinite(settings.sessionGoalDefaultBudget) && settings.sessionGoalDefaultBudget !== store.sessionGoalDefaultBudget) {
store.setSessionGoalDefaultBudget(settings.sessionGoalDefaultBudget);
}
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.autoSaveEnabled === 'boolean' && settings.autoSaveEnabled !== store.autoSaveEnabled) {
store.setAutoSaveEnabled(settings.autoSaveEnabled);
}
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.agentControlToolEnabled === 'boolean'
&& settings.agentControlToolEnabled !== store.agentControlToolEnabled
) {
store.setAgentControlToolEnabled(settings.agentControlToolEnabled);
}
if (
typeof settings.agentWebToolEnabled === 'boolean'
&& settings.agentWebToolEnabled !== store.agentWebToolEnabled
) {
store.setAgentWebToolEnabled(settings.agentWebToolEnabled);
}
if (
typeof settings.agentMemoryToolEnabled === 'boolean'
&& settings.agentMemoryToolEnabled !== store.agentMemoryToolEnabled
) {
store.setAgentMemoryToolEnabled(settings.agentMemoryToolEnabled);
}
// Server-owned: it says whether this build has the feature at all.
if (
typeof settings.agentMemoryFeatureAvailable === 'boolean'
&& settings.agentMemoryFeatureAvailable !== store.agentMemoryFeatureAvailable
) {
store.setAgentMemoryFeatureAvailable(settings.agentMemoryFeatureAvailable);
}
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.desktopWindowControlsPosition === 'string') {
const nextPosition = settings.desktopWindowControlsPosition === 'left'
? 'left'
: (settings.desktopWindowControlsPosition === 'right' || settings.desktopWindowControlsPosition === 'auto')
? 'right'
: null;
if (nextPosition && nextPosition !== store.desktopWindowControlsPosition) {
store.setDesktopWindowControlsPosition(nextPosition);
}
}
if (typeof settings.desktopWindowControlsStyle === 'string') {
const nextStyle = settings.desktopWindowControlsStyle === 'traffic-lights'
? 'traffic-lights'
: settings.desktopWindowControlsStyle === 'classic'
? 'classic'
: null;
if (nextStyle && nextStyle !== store.desktopWindowControlsStyle) {
store.setDesktopWindowControlsStyle(nextStyle);
}
}
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.promptNavigatorEnabled === 'boolean' && settings.promptNavigatorEnabled !== store.promptNavigatorEnabled) {
store.setPromptNavigatorEnabled(settings.promptNavigatorEnabled);
}
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)) {
let nextStarters = sanitizeStarterRefs(settings.draftStarters);
if (settings.draftStartersCraftGoalAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'craft-goal')) {
const planIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'plan-feature');
const insertAt = planIndex >= 0 ? planIndex + 1 : nextStarters.length;
nextStarters = [
...nextStarters.slice(0, insertAt),
{ type: 'command', name: 'craft-goal' },
...nextStarters.slice(insertAt),
];
}
if (settings.draftStartersScheduleTaskAdded !== true && !nextStarters.some((starter) => starter.type === 'command' && starter.name === 'schedule-task')) {
const goalIndex = nextStarters.findIndex((starter) => starter.type === 'command' && starter.name === 'craft-goal');
const insertAt = goalIndex >= 0 ? goalIndex + 1 : nextStarters.length;
nextStarters = [
...nextStarters.slice(0, insertAt),
{ type: 'command', name: 'schedule-task' },
...nextStarters.slice(insertAt),
];
}
if (JSON.stringify(store.globalDraftStarters) !== JSON.stringify(nextStarters)) {
store.setGlobalDraftStarters(nextStarters);
}
if (settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true) {
settings.draftStarters = nextStarters;
settings.draftStartersCraftGoalAdded = true;
settings.draftStartersScheduleTaskAdded = true;
}
} else {
// The built-in default already contains Craft a Goal and Schedule a Task;
// only persist the markers so removing them later remains a durable user
// choice.
if (settings.draftStartersCraftGoalAdded !== true) {
settings.draftStartersCraftGoalAdded = true;
}
if (settings.draftStartersScheduleTaskAdded !== true) {
settings.draftStartersScheduleTaskAdded = true;
}
}
if (typeof settings.draftStartersVisible === 'boolean' && settings.draftStartersVisible !== store.draftStartersVisible) {
store.setDraftStartersVisible(settings.draftStartersVisible);
}
if (typeof settings.terminalFontSize === 'number' && Number.isFinite(settings.terminalFontSize) && settings.terminalFontSize !== store.terminalFontSize) {
store.setTerminalFontSize(settings.terminalFontSize);
}
if (isTerminalShell(settings.terminalShell) && settings.terminalShell !== store.terminalShell) {
store.setTerminalShell(settings.terminalShell);
}
if (
Array.isArray(settings.terminalLoginShells)
&& (
settings.terminalLoginShells.length !== store.terminalLoginShells.length
|| settings.terminalLoginShells.some((shell, index) => shell !== store.terminalLoginShells[index])
)
) {
store.setTerminalLoginShells(settings.terminalLoginShells);
}
if (typeof settings.editorFontSize === 'number' && Number.isFinite(settings.editorFontSize) && settings.editorFontSize !== store.editorFontSize) {
store.setEditorFontSize(settings.editorFontSize);
}
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;
}
if (typeof candidate.desktopMacMenuBarEnabled === 'boolean') {
result.desktopMacMenuBarEnabled = candidate.desktopMacMenuBarEnabled;
}
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.draftStartersVisible === 'boolean') {
result.draftStartersVisible = candidate.draftStartersVisible;
}
if (typeof candidate.draftStartersCraftGoalAdded === 'boolean') {
result.draftStartersCraftGoalAdded = candidate.draftStartersCraftGoalAdded;
}
if (typeof candidate.draftStartersScheduleTaskAdded === 'boolean') {
result.draftStartersScheduleTaskAdded = candidate.draftStartersScheduleTaskAdded;
}
if (typeof candidate.workStatusPanelEnabled === 'boolean') {
result.workStatusPanelEnabled = candidate.workStatusPanelEnabled;
}
if (Array.isArray(candidate.workStatusHiddenSections)) {
// Unknown ids are dropped rather than kept: they would hide nothing and
// accumulate forever as sections get renamed.
result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections);
}
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.sessionGoalEnabled === 'boolean') {
result.sessionGoalEnabled = candidate.sessionGoalEnabled;
}
if (typeof candidate.sessionGoalDefaultBudgetEnabled === 'boolean') {
result.sessionGoalDefaultBudgetEnabled = candidate.sessionGoalDefaultBudgetEnabled;
}
if (typeof candidate.sessionGoalDefaultBudget === 'number' && Number.isFinite(candidate.sessionGoalDefaultBudget) && candidate.sessionGoalDefaultBudget > 0) {
result.sessionGoalDefaultBudget = Math.floor(candidate.sessionGoalDefaultBudget);
}
if (typeof candidate.collapsibleThinkingBlocks === 'boolean') {
result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks;
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
if (typeof candidate.autoSaveEnabled === 'boolean') {
result.autoSaveEnabled = candidate.autoSaveEnabled;
}
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.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) {
result.walkthroughModelOverride = candidate.walkthroughModelOverride;
}
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 (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
result.usageDisplayMode = candidate.usageDisplayMode;
}
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: NonNullable<DesktopSettings['usageModelGroups']>[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.agentControlToolEnabled === 'boolean') {
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
}
if (typeof candidate.agentWebToolEnabled === 'boolean') {
result.agentWebToolEnabled = candidate.agentWebToolEnabled;
}
if (typeof candidate.agentMemoryToolEnabled === 'boolean') {
result.agentMemoryToolEnabled = candidate.agentMemoryToolEnabled;
}
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.desktopWindowControlsPosition === 'string') {
if (candidate.desktopWindowControlsPosition === 'left') {
result.desktopWindowControlsPosition = 'left';
} else if (
candidate.desktopWindowControlsPosition === 'right'
|| candidate.desktopWindowControlsPosition === 'auto'
) {
// Legacy "auto" never read OS chrome config; treat as right.
result.desktopWindowControlsPosition = 'right';
}
}
if (typeof candidate.desktopWindowControlsStyle === 'string') {
if (candidate.desktopWindowControlsStyle === 'classic' || candidate.desktopWindowControlsStyle === 'traffic-lights') {
result.desktopWindowControlsStyle = candidate.desktopWindowControlsStyle;
}
}
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.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
}
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
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 (isTerminalShell(candidate.terminalShell)) {
result.terminalShell = candidate.terminalShell;
}
if (Array.isArray(candidate.terminalLoginShells)) {
result.terminalLoginShells = [...new Set(candidate.terminalLoginShells.filter(isTerminalShell))];
}
if (typeof candidate.editorFontSize === 'number' && Number.isFinite(candidate.editorFontSize)) {
result.editorFontSize = candidate.editorFontSize;
}
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;
};
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
let _settingsRuntimeGeneration = 0;
let _settingsCache: { value: DesktopSettings | null; at: number; context: SettingsRuntimeContext } | null = null;
let _settingsInflight: { promise: Promise<DesktopSettings | null>; context: SettingsRuntimeContext } | null = null;
let _pendingSettingsChanges: Partial<DesktopSettings> | null = null;
let _pendingSettingsContext: SettingsRuntimeContext | null = null;
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
let _settingsFlushWaiters: Array<() => void> = [];
let _settingsLifecycleInitialized = false;
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
const SETTINGS_DEBOUNCE_MS = 200;
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
runtimeKey: getRuntimeKey(),
generation: _settingsRuntimeGeneration,
});
const isSameSettingsRuntimeContext = (left: SettingsRuntimeContext, right: SettingsRuntimeContext): boolean => (
left.runtimeKey === right.runtimeKey && left.generation === right.generation
);
const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boolean => (
context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey()
);
const ensureSettingsRuntimeLifecycle = (): void => {
if (_settingsLifecycleInitialized || typeof window === 'undefined') return;
_settingsLifecycleInitialized = true;
subscribeRuntimeEndpointWillChange((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
if (_settingsFlushTimer) clearTimeout(_settingsFlushTimer);
if (_pendingSettingsChanges) void _flushSettingsUpdate();
});
subscribeRuntimeEndpointChanged((detail) => {
if (detail.runtimeKey === detail.previousRuntimeKey) return;
_settingsRuntimeGeneration += 1;
_settingsCache = null;
_settingsInflight = null;
});
};
const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise<DesktopSettings | null> => {
ensureSettingsRuntimeLifecycle();
// Return cached if fresh
if (_settingsCache && isSameSettingsRuntimeContext(_settingsCache.context, context) && Date.now() - _settingsCache.at < SETTINGS_CACHE_TTL) {
return _settingsCache.value;
}
// Dedup concurrent calls
if (_settingsInflight && isSameSettingsRuntimeContext(_settingsInflight.context, context)) return _settingsInflight.promise;
const inflight = {
context,
promise: (async (): Promise<DesktopSettings | null> => {
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(result.settings);
_settingsCache = { value: settings, at: Date.now(), context };
return settings;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
console.warn('Failed to load shared settings from runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return null;
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!isSettingsRuntimeContextCurrent(context)) return null;
if (!response.ok) {
return null;
}
const data = await response.json().catch(() => null);
if (!isSettingsRuntimeContextCurrent(context)) return null;
const settings = sanitizeWebSettings(data);
_settingsCache = { value: settings, at: Date.now(), context };
return settings;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return null;
console.warn('Failed to load shared settings from server:', error);
return null;
}
})(),
};
_settingsInflight = inflight;
void inflight.promise.finally(() => {
if (_settingsInflight === inflight) _settingsInflight = null;
});
return inflight.promise;
};
/** 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;
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
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) => {
if (!isSettingsRuntimeContextCurrent(context)) return;
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|| settings.draftStartersScheduleTaskAdded !== true;
// `autoSaveEnabled` is new to the settings backend. Until the server has a
// value, materialize would invent the client default (true) and overwrite a
// deliberate legacy "off" preference migrated from
// `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and
// seed the backend once so later omitted→default authority is correct.
const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean';
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
try {
persistToLocalStorage(settings);
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
if (shouldSeedAutoSaveEnabled) {
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
}
if (settings.draftStarters === undefined) {
useUIStore.setState({ globalDraftStarters: null });
}
try {
applyDesktopUiPreferences(authoritativeSettings);
} catch (error) {
console.warn('applyDesktopUiPreferences failed:', error);
}
const migrationPatch: Partial<DesktopSettings> = {};
if (shouldPersistCraftGoalMigration) {
if (authoritativeSettings.draftStarters) {
migrationPatch.draftStarters = authoritativeSettings.draftStarters;
}
migrationPatch.draftStartersCraftGoalAdded = true;
migrationPatch.draftStartersScheduleTaskAdded = true;
}
if (shouldSeedAutoSaveEnabled) {
migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled;
}
if (Object.keys(migrationPatch).length > 0) {
await updateDesktopSettings(migrationPatch);
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(authoritativeSettings);
};
try {
const webSettings = await fetchWebSettings(context);
if (webSettings && isSettingsRuntimeContextCurrent(context)) {
await applySettings(webSettings);
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
}
};
// Coalesce rapid updateDesktopSettings calls into a single PUT
async function _flushSettingsUpdate(): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const waiters = _settingsFlushWaiters;
_pendingSettingsChanges = null;
_pendingSettingsContext = null;
_settingsFlushTimer = null;
_settingsFlushWaiters = [];
try {
if (!changes || !context || Object.keys(changes).length === 0 || !isSettingsRuntimeContextCurrent(context)) {
// Nothing will be written — clear any pending "Saving…" indicator.
dispatchSettingsSaveState('saved');
return;
}
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
try {
const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
_settingsCache = null;
}
dispatchSettingsSaveState(updated ? 'saved' : 'error');
return;
} catch (error) {
if (!isSettingsRuntimeContextCurrent(context)) return;
console.warn('Failed to update settings via runtime settings API:', error);
}
}
if (!isSettingsRuntimeContextCurrent(context)) return;
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!isSettingsRuntimeContextCurrent(context)) return;
if (!response.ok) {
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
dispatchSettingsSaveState('error');
return;
}
const updated = sanitizeWebSettings(await response.json().catch(() => null));
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
// Invalidate GET cache so next read sees the fresh data
_settingsCache = null;
} else {
dispatchSettingsSaveState('error');
}
} catch (error) {
if (isSettingsRuntimeContextCurrent(context)) {
console.warn('Failed to update shared settings via API:', error);
dispatchSettingsSaveState('error');
}
}
} finally {
waiters.forEach((resolve) => resolve());
}
}
export const updateDesktopSettings = async (changes: Partial<DesktopSettings>): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
ensureSettingsRuntimeLifecycle();
const context = captureSettingsRuntimeContext();
if (_pendingSettingsContext && !isSameSettingsRuntimeContext(_pendingSettingsContext, context)) {
if (_settingsFlushTimer) clearTimeout(_settingsFlushTimer);
void _flushSettingsUpdate();
}
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
_pendingSettingsContext = context;
dispatchSettingsSaveState('saving');
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);
}
};