Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates UI preferences from browser localStorage to Electron's settings.json file for better persistence and integration with the desktop app architecture. The migration includes theme preferences, response styles, pricing display, session sharing configuration, and announcement tracking.
Changes:
- Adds new settings fields to the Settings interface for UI preferences (theme, responseStyle, showPricing, sessionSharing, seenAnnouncementIds)
- Implements getSetting/setSetting IPC handlers for granular settings access
- Updates all components to use window.electron.getSetting/setSetting instead of localStorage
- References a migration utility to move existing localStorage data (file not included in PR)
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/desktop/src/utils/settings.ts | Adds UI preference fields to Settings interface and default values |
| ui/desktop/src/main.ts | Implements get-setting/set-setting IPC handlers, removes duplicate default settings definition |
| ui/desktop/src/preload.ts | Exposes getSetting/setSetting methods to renderer process with type safety |
| ui/desktop/src/renderer.tsx | Imports and calls migration function on app startup |
| ui/desktop/src/contexts/ThemeContext.tsx | Replaces localStorage with settings API for theme preferences |
| ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx | Updates to use settings API instead of localStorage |
| ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx | Migrates response style storage to settings API |
| ui/desktop/src/components/settings/app/AppSettingsSection.tsx | Updates pricing toggle to use settings API |
| ui/desktop/src/components/sessions/SessionHistoryView.tsx | Reads session sharing config from settings |
| ui/desktop/src/components/bottom_menu/CostTracker.tsx | Updates to listen for pricing changes via custom event |
| ui/desktop/src/components/ToolCallWithResponse.tsx | Loads response style from settings instead of localStorage |
| ui/desktop/src/components/AnnouncementModal.tsx | Stores seen announcements in settings instead of localStorage |
| ui/desktop/src/sessionLinks.ts | Reads session sharing config from settings |
| ui/desktop/package-lock.json | Updates peer dependency resolution for local packages |
Files not reviewed (1)
- ui/desktop/package-lock.json: Language not supported
| // Re-register shortcuts if keyboard shortcuts changed | ||
| if (key === 'keyboardShortcuts') { | ||
| registerGlobalShortcuts(); | ||
| } |
There was a problem hiding this comment.
The set-setting handler doesn't return a value, but the preload API expects a Promise<void>. While this might work, it's inconsistent with the save-settings handler which returns true. Consider returning a value for consistency and to match the type signature.
| } | |
| } | |
| return true; |
05c11b2 to
e7c6b5c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
ui/desktop/src/utils/settings.ts:92
keyboardShortcutsis now non-optional inSettings, butgetKeyboardShortcuts()still relies on!settings.keyboardShortcutsto migrate legacyglobalShortcut-> shortcuts; with defaults merged on load this branch will never run, so users with onlyglobalShortcutset will silently lose their custom shortcut.
Either keepkeyboardShortcutsoptional for migration, or move the migration logic to settings-load time using the raw stored object before defaults are applied.
spellcheckEnabled: boolean;
externalGoosed: ExternalGoosedConfig;
globalShortcut?: string | null;
keyboardShortcuts: KeyboardShortcuts;
// UI preferences (migrated from localStorage)
theme: 'dark' | 'light';
useSystemTheme: boolean;
responseStyle: string;
showPricing: boolean;
sessionSharing: SessionSharingConfig;
seenAnnouncementIds: string[];
}
export type SettingKey = keyof Settings;
export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = {
focusWindow: 'CommandOrControl+Alt+G',
quickLauncher: 'CommandOrControl+Alt+Shift+G',
newChat: 'CommandOrControl+T',
newChatWindow: 'CommandOrControl+N',
openDirectory: 'CommandOrControl+O',
settings: 'CommandOrControl+,',
find: 'CommandOrControl+F',
findNext: 'CommandOrControl+G',
findPrevious: 'CommandOrControl+Shift+G',
alwaysOnTop: 'CommandOrControl+Shift+T',
};
export const defaultSettings: Settings = {
// Desktop app settings
showMenuBarIcon: true,
showDockIcon: true,
enableWakelock: false,
spellcheckEnabled: true,
keyboardShortcuts: defaultKeyboardShortcuts,
externalGoosed: {
enabled: false,
url: '',
secret: '',
},
// UI preferences
theme: 'light',
useSystemTheme: true,
responseStyle: 'concise',
showPricing: true,
sessionSharing: {
enabled: false,
baseUrl: '',
},
seenAnnouncementIds: [],
};
export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {
if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) {
const focusShortcut = settings.globalShortcut;
let launcherShortcut: string | null = null;
| useEffect(() => { | ||
| const stored = localStorage.getItem('show_pricing'); | ||
| setShowPricing(stored !== 'false'); | ||
| window.electron.getSetting('showPricing').then(setShowPricing); |
There was a problem hiding this comment.
window.electron.getSetting('showPricing').then(setShowPricing) has no .catch(...); if the IPC call rejects, this becomes an unhandled promise rejection in the renderer.
Handle errors (e.g. catch + keep the default true) so a corrupted settings file doesn't break the settings UI.
| window.electron.getSetting('showPricing').then(setShowPricing); | |
| window.electron | |
| .getSetting('showPricing') | |
| .then((value) => { | |
| if (typeof value === 'boolean') { | |
| setShowPricing(value); | |
| } | |
| }) | |
| .catch(() => { | |
| // Keep default `true` if the setting cannot be loaded | |
| }); |
ui/desktop/src/main.ts
Outdated
| const stored = JSON.parse(data); | ||
| // Merge with defaults to ensure all keys exist | ||
| return { ...defaultSettings, ...stored }; |
There was a problem hiding this comment.
getSettings() does a shallow merge ({ ...defaultSettings, ...stored }), which will drop nested defaults when a stored nested object is partial (e.g. externalGoosed.secret or individual keyboardShortcuts keys), leading to undefined fields at runtime.
Consider normalizing with a per-nested-key merge (or schema validation) so nested objects are merged with their defaults instead of replaced wholesale.
| const stored = JSON.parse(data); | |
| // Merge with defaults to ensure all keys exist | |
| return { ...defaultSettings, ...stored }; | |
| const stored = JSON.parse(data) as Partial<Settings>; | |
| // Merge with defaults to ensure all keys exist, including nested objects | |
| const merged: Settings = { | |
| ...defaultSettings, | |
| ...stored, | |
| externalGoosed: { | |
| ...defaultSettings.externalGoosed, | |
| ...(stored.externalGoosed ?? {}), | |
| }, | |
| keyboardShortcuts: { | |
| ...defaultSettings.keyboardShortcuts, | |
| ...(stored.keyboardShortcuts ?? {}), | |
| }, | |
| }; | |
| return merged; |
| ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { | ||
| const settings = getSettings(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| (settings as any)[key] = value; | ||
| fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); |
There was a problem hiding this comment.
The set-setting IPC handler writes (settings as any)[key] = value without runtime validation of key; a compromised renderer could pass __proto__ / constructor and trigger prototype pollution or mutate unexpected fields.
Add a runtime whitelist check against known SettingKey values (and explicitly block prototype keys) before applying the update.
| return (rawValue === 'true') as unknown as Settings[K]; | ||
| case 'responseStyle': | ||
| return rawValue as Settings[K]; | ||
| case 'showPricing': | ||
| return (rawValue === 'true') as unknown as Settings[K]; |
There was a problem hiding this comment.
parseLocalStorageValue() treats any non-"true" value as false for boolean settings (e.g. useSystemTheme, showPricing), so a corrupted or unexpected localStorage value will be migrated into settings as false instead of being ignored.
Prefer returning null unless the raw value is exactly "true" or "false" (and optionally clear the bad localStorage key) to avoid silently changing user preferences during migration.
| return (rawValue === 'true') as unknown as Settings[K]; | |
| case 'responseStyle': | |
| return rawValue as Settings[K]; | |
| case 'showPricing': | |
| return (rawValue === 'true') as unknown as Settings[K]; | |
| if (rawValue === 'true') { | |
| return true as unknown as Settings[K]; | |
| } | |
| if (rawValue === 'false') { | |
| return false as unknown as Settings[K]; | |
| } | |
| return null; | |
| case 'responseStyle': | |
| return rawValue as Settings[K]; | |
| case 'showPricing': | |
| if (rawValue === 'true') { | |
| return true as unknown as Settings[K]; | |
| } | |
| if (rawValue === 'false') { | |
| return false as unknown as Settings[K]; | |
| } | |
| return null; |
| window.electron.getSetting('sessionSharing').then((config) => { | ||
| setSessionSharingConfig(config); | ||
| }); |
There was a problem hiding this comment.
The getSetting('sessionSharing') call in the effect isn't error-handled; if the IPC invoke rejects (e.g. settings file parse error) this can produce an unhandled promise rejection and leave the UI in an inconsistent state.
Wrap the async load in a try/catch (or add .catch(...)) and fall back to the current in-memory defaults on failure.
| window.electron.getSetting('sessionSharing').then((config) => { | |
| setSessionSharingConfig(config); | |
| }); | |
| window.electron | |
| .getSetting('sessionSharing') | |
| .then((config) => { | |
| setSessionSharingConfig(config); | |
| }) | |
| .catch((error) => { | |
| // Fall back to current in-memory defaults on failure. | |
| console.error('Failed to load session sharing settings, using defaults', error); | |
| }); |
zanesq
left a comment
There was a problem hiding this comment.
Thanks for tackling this! Tested locally and looks good. Only piece of feedback from goose worth noting is:
Prototype Pollution Protection is Good, But (settings as any)[key] = value is Risky
The validSettingKeys allowlist in main.ts is a good security measure. However, the value parameter is completely unvalidated — a renderer compromise could write arbitrary data structures into any allowed key. For example, writing a massive blob to seenAnnouncementIds or a non-object to keyboardShortcuts.
Recommendation: Consider adding basic type validation per key (e.g., typeof value === 'boolean' for boolean keys, Array.isArray(value) for array keys). Not critical for v1, but worth a TODO.
|
Thanks for testing, @zanesq
yeah, I don't know - we're not protecting against an compromised renderer here. even checking the keys seems not all that useful since typescript should protect us. but that is something somebody might override easier, I think. if you can think of a way to this with introspection rather than explicit, I'd be up for it |
* origin/main: doc: groq models (#7404) Client settings (#7381) Fix settings tabs getting cut off in narrow windows (#7379) docs: voice dictation updates (#7396) [docs] Add Excalidraw MCP App Tutorial (#7401) Post release checklist as a comment on release PRs (#7307) unique api key (#7391) fix: use correct colors for download progress bar (#7390) Add local model settings access from bottom bar model menu (#7378) Change Recipe Security Scanner API key (#7387) switch Ask AI Discord bot from openrouter to anthropic (#7386)
* main: (27 commits) dev: add cmake to hermitized env (#7399) refactor: remove allows_unlisted_models flag, always allow custom model entry (#7255) feat: expose context window utilization to agent via MOIM (#7418) Small model naming (#7394) chore(deps): bump ajv in /documentation (#7416) doc: groq models (#7404) Client settings (#7381) Fix settings tabs getting cut off in narrow windows (#7379) docs: voice dictation updates (#7396) [docs] Add Excalidraw MCP App Tutorial (#7401) Post release checklist as a comment on release PRs (#7307) unique api key (#7391) fix: use correct colors for download progress bar (#7390) Add local model settings access from bottom bar model menu (#7378) Change Recipe Security Scanner API key (#7387) switch Ask AI Discord bot from openrouter to anthropic (#7386) feat(ui): show token counts directly for "free" providers (#7383) Update creator note (#7384) Remove display_name from local model API and use model ID everywhere (#7382) fix(summon): stop MOIM from telling models to sleep while waiting for tasks (#7377) ...
* main: (73 commits) dev: add cmake to hermitized env (#7399) refactor: remove allows_unlisted_models flag, always allow custom model entry (#7255) feat: expose context window utilization to agent via MOIM (#7418) Small model naming (#7394) chore(deps): bump ajv in /documentation (#7416) doc: groq models (#7404) Client settings (#7381) Fix settings tabs getting cut off in narrow windows (#7379) docs: voice dictation updates (#7396) [docs] Add Excalidraw MCP App Tutorial (#7401) Post release checklist as a comment on release PRs (#7307) unique api key (#7391) fix: use correct colors for download progress bar (#7390) Add local model settings access from bottom bar model menu (#7378) Change Recipe Security Scanner API key (#7387) switch Ask AI Discord bot from openrouter to anthropic (#7386) feat(ui): show token counts directly for "free" providers (#7383) Update creator note (#7384) Remove display_name from local model API and use model ID everywhere (#7382) fix(summon): stop MOIM from telling models to sleep while waiting for tasks (#7377) ...
…oviders * 'main' of github.com:block/goose: New navigation settings layout options and styling (#6645) refactor: MCP-compliant theme tokens and CSS class rename (#7275) Redirect llama.cpp logs through tracing to avoid polluting CLI stdout/stderr (#7434) refactor: change open recipe in new window to pass recipe id (#7392) fix: handle truncated tool calls that break conversation alternation (#7424) streamline some github actions (#7430) Enable bedrock prompt cache (#6710) fix: use BEGIN IMMEDIATE to prevent SQLite deadlocks (#7429) Display working dir (#7419) dev: add cmake to hermitized env (#7399) refactor: remove allows_unlisted_models flag, always allow custom model entry (#7255) feat: expose context window utilization to agent via MOIM (#7418) Small model naming (#7394) chore(deps): bump ajv in /documentation (#7416) doc: groq models (#7404) Client settings (#7381) Fix settings tabs getting cut off in narrow windows (#7379) # Conflicts: # ui/desktop/src/components/settings/dictation/DictationSettings.tsx
* origin/main: (49 commits) add flag to hide select voice providers (#7406) New navigation settings layout options and styling (#6645) refactor: MCP-compliant theme tokens and CSS class rename (#7275) Redirect llama.cpp logs through tracing to avoid polluting CLI stdout/stderr (#7434) refactor: change open recipe in new window to pass recipe id (#7392) fix: handle truncated tool calls that break conversation alternation (#7424) streamline some github actions (#7430) Enable bedrock prompt cache (#6710) fix: use BEGIN IMMEDIATE to prevent SQLite deadlocks (#7429) Display working dir (#7419) dev: add cmake to hermitized env (#7399) refactor: remove allows_unlisted_models flag, always allow custom model entry (#7255) feat: expose context window utilization to agent via MOIM (#7418) Small model naming (#7394) chore(deps): bump ajv in /documentation (#7416) doc: groq models (#7404) Client settings (#7381) Fix settings tabs getting cut off in narrow windows (#7379) docs: voice dictation updates (#7396) [docs] Add Excalidraw MCP App Tutorial (#7401) ... # Conflicts: # ui/desktop/src/components/McpApps/McpAppRenderer.tsx
Summary
Move away from using localStorage