feat(ui): add spell check support for text inputs (#1304)

* feat: add spell check with context menu and language sync

Enables spell checking in text inputs with:
- Right-click context menu with spelling suggestions
- "Add to Dictionary" option (localized for en/fr)
- Standard editing options (cut/copy/paste/select all)
- Spell check language syncs with i18n app language
- Input/Textarea components default spellCheck=true and lang attribute

Files:
- app-language.ts: Tracks app language for context menu labels
- spellcheck.ts: Language mapping and localized labels
- index.ts: Context menu handler with spell check integration
- settings-handlers.ts: IPC handler for language switching
- App.tsx: Syncs spell check language with i18n changes

Supersedes PR #1304 (rebased from conflicting branch)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use i18n.language in useEffect dependency instead of i18n object

The i18n object reference from react-i18next can change on every render
even when the language hasn't changed, causing the effect to fire more
frequently than necessary and making unnecessary IPC calls.

Changed dependency from [settings.language, i18n] to
[settings.language, i18n.language] to only re-run when the actual
language changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: call initAppLanguage() on startup for immediate locale sync

initAppLanguage() was defined but never called, leaving the main process
language hardcoded to 'en' until the renderer sent the first IPC sync.
Now called in app.whenReady() so context menu labels (e.g. "Add to
Dictionary") are localized immediately from the OS locale.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
kaigler
2026-01-30 04:43:49 -06:00
committed by GitHub
parent 1e19971679
commit 8f02a51297
12 changed files with 367 additions and 99 deletions
+45
View File
@@ -0,0 +1,45 @@
/**
* App language tracking module for main process.
*
* Tracks the user's in-app language setting (not OS locale) for use in
* main process code that needs localized strings (e.g., context menus).
*
* Updated via IPC when user changes language in settings.
*/
import { app } from 'electron';
// Current app language, defaults to 'en'
// Updated via setAppLanguage() when renderer notifies of language change
let currentAppLanguage = 'en';
/**
* Get the current app language.
* Falls back to 'en' if not set.
*/
export function getAppLanguage(): string {
return currentAppLanguage;
}
/**
* Set the current app language.
* Called by IPC handler when renderer changes language.
*/
export function setAppLanguage(language: string): void {
currentAppLanguage = language;
}
/**
* Initialize app language from OS locale as a starting point.
* The renderer will update this once i18n initializes.
*/
export function initAppLanguage(): void {
try {
// app.getLocale() may not be available in test environments
const osLocale = app?.getLocale?.() || 'en';
// Extract base language (e.g., 'en-US' -> 'en')
currentAppLanguage = osLocale.split('-')[0] || 'en';
} catch {
currentAppLanguage = 'en';
}
}
+89 -3
View File
@@ -35,7 +35,7 @@ for (const envPath of possibleEnvPaths) {
}
}
import { app, BrowserWindow, shell, nativeImage, session, screen } from 'electron';
import { app, BrowserWindow, shell, nativeImage, session, screen, Menu, MenuItem } from 'electron';
import { join } from 'path';
import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
@@ -46,7 +46,8 @@ import { pythonEnvManager } from './python-env-manager';
import { getUsageMonitor } from './claude-profile/usage-monitor';
import { initializeUsageMonitorForwarding } from './ipc-handlers/terminal-handlers';
import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater';
import { DEFAULT_APP_SETTINGS, IPC_CHANNELS } from '../shared/constants';
import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants';
import { getAppLanguage, initAppLanguage } from './app-language';
import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger';
import { initSentryMain } from './sentry';
@@ -204,7 +205,8 @@ function createWindow(): void {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false // Prevent terminal lag when window loses focus
backgroundThrottling: false, // Prevent terminal lag when window loses focus
spellcheck: true // Enable spell check for text inputs
}
});
@@ -213,6 +215,87 @@ function createWindow(): void {
mainWindow?.show();
});
// Configure initial spell check languages with proper fallback logic
// Uses shared constant for consistency with the IPC handler
const defaultLanguage = 'en';
const defaultSpellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[defaultLanguage] || [DEFAULT_SPELL_CHECK_LANGUAGE];
const availableSpellCheckLanguages = session.defaultSession.availableSpellCheckerLanguages;
const validSpellCheckLanguages = defaultSpellCheckLanguages.filter(lang =>
availableSpellCheckLanguages.includes(lang)
);
const initialSpellCheckLanguages = validSpellCheckLanguages.length > 0
? validSpellCheckLanguages
: (availableSpellCheckLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []);
if (initialSpellCheckLanguages.length > 0) {
session.defaultSession.setSpellCheckerLanguages(initialSpellCheckLanguages);
console.log(`[SPELLCHECK] Initial languages set to: ${initialSpellCheckLanguages.join(', ')}`);
} else {
console.warn('[SPELLCHECK] No spell check languages available on this system');
}
// Handle context menu with spell check and standard editing options
mainWindow.webContents.on('context-menu', (_event, params) => {
const menu = new Menu();
// Add spelling suggestions if there's a misspelled word
if (params.misspelledWord) {
for (const suggestion of params.dictionarySuggestions) {
menu.append(new MenuItem({
label: suggestion,
click: () => mainWindow?.webContents.replaceMisspelling(suggestion)
}));
}
if (params.dictionarySuggestions.length > 0) {
menu.append(new MenuItem({ type: 'separator' }));
}
// Use localized label for "Add to Dictionary" based on app language (not OS locale)
// getAppLanguage() tracks the user's in-app language setting, updated via SPELLCHECK_SET_LANGUAGES IPC
const addToDictionaryLabel = ADD_TO_DICTIONARY_LABELS[getAppLanguage()] || ADD_TO_DICTIONARY_LABELS['en'];
menu.append(new MenuItem({
label: addToDictionaryLabel,
click: () => mainWindow?.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
}));
menu.append(new MenuItem({ type: 'separator' }));
}
// Standard editing options for editable fields
// Using role without explicit label allows Electron to provide localized labels
if (params.isEditable) {
menu.append(new MenuItem({
role: 'cut',
enabled: params.editFlags.canCut
}));
menu.append(new MenuItem({
role: 'copy',
enabled: params.editFlags.canCopy
}));
menu.append(new MenuItem({
role: 'paste',
enabled: params.editFlags.canPaste
}));
menu.append(new MenuItem({
role: 'selectAll',
enabled: params.editFlags.canSelectAll
}));
} else if (params.selectionText?.trim()) {
// Non-editable text selection (e.g., labels, paragraphs)
// Use .trim() to avoid showing menu for whitespace-only selections
menu.append(new MenuItem({
role: 'copy',
enabled: params.editFlags.canCopy
}));
}
// Only show menu if there are items
if (menu.items.length > 0) {
menu.popup();
}
});
// Handle external links with URL scheme allowlist for security
// Note: Terminal links now use IPC via WebLinksAddon callback, but this handler
// catches any other window.open() calls (e.g., from third-party libraries)
@@ -278,6 +361,9 @@ app.whenReady().then(() => {
.catch((err) => console.warn('[main] Failed to clear cache:', err));
}
// Initialize app language from OS locale for main process i18n (context menus)
initAppLanguage();
// Clean up stale update metadata from the old source updater system
// This prevents version display desync after electron-updater installs a new version
cleanupStaleUpdateMetadata();
@@ -1,4 +1,4 @@
import { ipcMain, dialog, app, shell } from 'electron';
import { ipcMain, dialog, app, shell, session } from 'electron';
import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs';
import { execFileSync } from 'node:child_process';
import path from 'path';
@@ -8,7 +8,8 @@ import { is } from '@electron-toolkit/utils';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants';
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE } from '../../shared/constants';
import { setAppLanguage } from '../app-language';
import type {
AppSettings,
IPCResult,
@@ -801,4 +802,64 @@ export function registerSettingsHandlers(
}
}
);
// ============================================
// Spell Check Operations
// ============================================
/**
* Set spell check languages based on app language.
* Called when renderer's i18n language changes to sync spell checker.
*/
ipcMain.handle(
IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES,
async (_, language: string): Promise<IPCResult<{ success: boolean }>> => {
try {
// Validate language parameter
if (!language || typeof language !== 'string') {
return {
success: false,
error: 'Invalid language parameter'
};
}
// Update tracked app language for context menu labels
setAppLanguage(language);
// Get spell check languages for this app language
const spellCheckLanguages = SPELL_CHECK_LANGUAGE_MAP[language] || [DEFAULT_SPELL_CHECK_LANGUAGE];
// Get available languages on this system
const availableLanguages = session.defaultSession.availableSpellCheckerLanguages;
// Filter to only available languages
const validLanguages = spellCheckLanguages.filter(lang =>
availableLanguages.includes(lang)
);
// Fallback to default if none of the preferred languages are available
const languagesToSet = validLanguages.length > 0
? validLanguages
: (availableLanguages.includes(DEFAULT_SPELL_CHECK_LANGUAGE) ? [DEFAULT_SPELL_CHECK_LANGUAGE] : []);
if (languagesToSet.length > 0) {
session.defaultSession.setSpellCheckerLanguages(languagesToSet);
console.log(`[SPELLCHECK] Languages set to: ${languagesToSet.join(', ')} for app language: ${language}`);
} else {
console.warn(`[SPELLCHECK] No valid spell check languages available for: ${language}`);
}
return {
success: true,
data: { success: true }
};
} catch (error) {
console.error('[SPELLCHECK_SET_LANGUAGES] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set spell check languages'
};
}
}
);
}
@@ -36,6 +36,9 @@ export interface SettingsAPI {
notifySentryStateChanged: (enabled: boolean) => void;
getSentryDsn: () => Promise<string>;
getSentryConfig: () => Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }>;
// Spell check
setSpellCheckLanguages: (language: string) => Promise<IPCResult<{ success: boolean }>>;
}
export const createSettingsAPI = (): SettingsAPI => ({
@@ -83,5 +86,9 @@ export const createSettingsAPI = (): SettingsAPI => ({
// Get full Sentry config from main process (DSN + sample rates)
getSentryConfig: (): Promise<{ dsn: string; tracesSampleRate: number; profilesSampleRate: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG)
ipcRenderer.invoke(IPC_CHANNELS.GET_SENTRY_CONFIG),
// Spell check - sync spell checker language with app language
setSpellCheckLanguages: (language: string): Promise<IPCResult<{ success: boolean }>> =>
ipcRenderer.invoke(IPC_CHANNELS.SPELLCHECK_SET_LANGUAGES, language)
});
+18 -1
View File
@@ -313,7 +313,24 @@ export function App() {
if (settings.language && settings.language !== i18n.language) {
i18n.changeLanguage(settings.language);
}
}, [settings.language, i18n]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change
}, [settings.language, i18n.language]);
// Sync spell check language with i18n language
useEffect(() => {
const syncSpellCheck = async () => {
try {
const result = await window.electronAPI.setSpellCheckLanguages(i18n.language);
if (!result.success) {
console.warn('[App] Failed to set spell check language:', result.error);
}
} catch (error) {
console.warn('[App] Error syncing spell check language:', error);
}
};
syncSpellCheck();
}, [i18n.language]);
// Listen for open-app-settings events (e.g., from project settings)
useEffect(() => {
@@ -1,28 +1,33 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-200',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, spellCheck, lang, ...props }, ref) => {
const { i18n } = useTranslation();
return (
<input
type={type}
spellCheck={spellCheck ?? true}
lang={lang ?? i18n.language}
className={cn(
'flex h-10 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-200',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
@@ -1,27 +1,32 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-200',
'resize-none',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, spellCheck, lang, ...props }, ref) => {
const { i18n } = useTranslation();
return (
<textarea
spellCheck={spellCheck ?? true}
lang={lang ?? i18n.language}
className={cn(
'flex min-h-[80px] w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-200',
'resize-none',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };
@@ -20,6 +20,9 @@ export const settingsMock = {
getSentryDsn: async () => '', // No DSN in browser mode
getSentryConfig: async () => ({ dsn: '', tracesSampleRate: 0, profilesSampleRate: 0 }),
// Spell check (no-op in browser mode)
setSpellCheckLanguages: async () => ({ success: true, data: { success: true } }),
getCliToolsInfo: async () => ({
success: true,
data: {
+40 -37
View File
@@ -1,37 +1,40 @@
/**
* Central export point for all constants
* Re-exports from domain-specific constant modules
*/
// Phase event protocol constants (Python ↔ TypeScript)
export * from './phase-protocol';
// IPC Channel constants
export * from './ipc';
// Task-related constants
export * from './task';
// Roadmap constants
export * from './roadmap';
// Ideation constants
export * from './ideation';
// Changelog constants
export * from './changelog';
// Model and agent profile constants
export * from './models';
// Theme constants
export * from './themes';
// GitHub integration constants
export * from './github';
// API profile presets
export * from './api-profiles';
// Configuration and paths
export * from './config';
/**
* Central export point for all constants
* Re-exports from domain-specific constant modules
*/
// Phase event protocol constants (Python ↔ TypeScript)
export * from './phase-protocol';
// IPC Channel constants
export * from './ipc';
// Task-related constants
export * from './task';
// Roadmap constants
export * from './roadmap';
// Ideation constants
export * from './ideation';
// Changelog constants
export * from './changelog';
// Model and agent profile constants
export * from './models';
// Theme constants
export * from './themes';
// GitHub integration constants
export * from './github';
// API profile presets
export * from './api-profiles';
// Configuration and paths
export * from './config';
// Spell check configuration
export * from './spellcheck';
@@ -554,6 +554,9 @@ export const IPC_CHANNELS = {
GET_SENTRY_DSN: 'sentry:get-dsn', // Get DSN from main process (env var)
GET_SENTRY_CONFIG: 'sentry:get-config', // Get full Sentry config (DSN + sample rates)
// Spell check
SPELLCHECK_SET_LANGUAGES: 'spellcheck:setLanguages', // Set spell check language (syncs with i18n)
// Screenshot capture
SCREENSHOT_GET_SOURCES: 'screenshot:getSources', // Get available screens/windows
SCREENSHOT_CAPTURE: 'screenshot:capture', // Capture screenshot from source
@@ -0,0 +1,30 @@
/**
* Spell check language configuration constants.
*
* Maps app language codes to Chromium spell checker language codes.
* Electron uses Chromium's spell checker which may use different codes
* than standard locale codes (e.g., 'en-US' vs 'en').
*/
/**
* Map app language codes to spell checker language codes.
* Each app language can map to multiple spell checker languages for better coverage.
*/
export const SPELL_CHECK_LANGUAGE_MAP: Record<string, string[]> = {
en: ['en-US', 'en-GB'],
fr: ['fr-FR', 'fr'],
};
/**
* Default spell check language when the preferred language isn't available.
*/
export const DEFAULT_SPELL_CHECK_LANGUAGE = 'en-US';
/**
* Localized labels for "Add to Dictionary" context menu item.
* Uses app language (not OS locale) to match the in-app language setting.
*/
export const ADD_TO_DICTIONARY_LABELS: Record<string, string> = {
en: 'Add to Dictionary',
fr: 'Ajouter au dictionnaire',
};
+3
View File
@@ -342,6 +342,9 @@ export interface ElectronAPI {
getSettings: () => Promise<IPCResult<AppSettings>>;
saveSettings: (settings: Partial<AppSettings>) => Promise<IPCResult>;
// Spell check
setSpellCheckLanguages: (language: string) => Promise<IPCResult<{ success: boolean }>>;
// Sentry error reporting
notifySentryStateChanged: (enabled: boolean) => void;
getSentryDsn: () => Promise<string>;