The preview panel worked by proxying a dev server through OpenChamber's own origin and rewriting the HTML that came back. Anything the rewriter did not anticipate broke, and pages that refuse to be embedded never loaded at all. This deletes the proxy (-1604 lines and its tests) and merges the preview and browser panels into one surface backed by a real Chromium view. What the panel is now - A `<webview>` in its own session partition: logins and cookies persist, hot reload works because nothing is rewritten, DevTools are one click away. - Annotation: pick one element, drag a region, or draw freehand, write a note, and it reaches chat with a screenshot of the visible page with the marks on it. - Toolbar: hard reload, page zoom, device sizes, a light/dark switch that applies to the page rather than the app, and cookie/cache clearing scoped to the panel alone. - Several pages at once, each tab showing the page's own favicon, and an address bar that suggests pages already visited in this project. - Dev servers are listed from what is actually listening on the machine, checked against what a project announced, so a server is offered no matter how it was started. One that is still starting is waited for instead of failing. Remote dev servers The desktop app binds a local port and pipes raw bytes to the OpenChamber host over the existing authenticated connection, so the page keeps its own origin at the root of its own host. The reachable set is exactly what discovery reports and is re-checked per connection, so an authenticated client cannot dial arbitrary local services on the host. Links and redirects to another loopback port stay on the machine that served the page. A tunnel that cannot be opened is reported; it is never replaced by the plain loopback URL, which would answer from the user's own machine under a remote address. Agent control Browser actions are a separate `openchamber_web` tool: open, snapshot, click, type, scroll, inspect computed styles, resize between mobile/tablet/desktop, and capture a screenshot into `.openchamber/screenshots/` in the project. The existing `openchamber` tool keeps sessions, worktrees and scheduled tasks. Each has its own setting in the new Settings -> General -> OpenChamber Tools section, and the plugin is not injected at all when both are off. Capability belongs to the connected client, not to configuration: a client declares on its event stream that it can drive a page, which only a Chromium host does. Exactly one client performs each request — it claims the request before acting, and the first claim wins — because deciding by whose result arrives first would be too late for a click that already happened. No client listening is answered immediately with an explanation rather than a timeout. Runtime boundaries Web tabs get a plain iframe that can display a page but not inspect one. The VS Code extension no longer offers the surface at all, since nothing that makes the panel worth having works there. Mobile is unaffected. Native boundary Camera, microphone, location and device-picker requests from panel pages are denied — Electron grants them by default when no handler is set, and the panel loads whatever address the user types. Page capture, appearance emulation and storage clearing verify that their target belongs to the panel's own session instead of trusting a web-contents id from the renderer. Persisted state Stored `preview` tabs migrate to `browser` (v13 -> v14). Context panel tab limits are now per surface, so filling one surface no longer evicts another's tabs. Address history is stored per project and per runtime. Documentation `preview.mdx` and `desktop-browser.mdx` rewritten across all locales, the agent tool settings path corrected, new `DOCUMENTATION.md` for the browser-control broker and the dev tunnel, and the `ui-api-decoupling` skill updated where it still described the deleted proxy.
1957 lines
84 KiB
TypeScript
1957 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 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');
|
|
}
|
|
} 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);
|
|
|
|
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 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,
|
|
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.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: {
|
|
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.agentControlToolEnabled === 'boolean') {
|
|
result.agentControlToolEnabled = candidate.agentControlToolEnabled;
|
|
}
|
|
if (typeof candidate.agentWebToolEnabled === 'boolean') {
|
|
result.agentWebToolEnabled = candidate.agentWebToolEnabled;
|
|
}
|
|
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 = (await response.json().catch(() => null)) as DesktopSettings | 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);
|
|
}
|
|
};
|