From c056ce10fc870f2180ad06f0c144c785c242641f Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 00:45:59 -0400 Subject: [PATCH 01/92] MAESTRO: add i18n infrastructure with i18next, locale detection, and English base translations Install i18next, react-i18next, i18next-browser-languagedetector, i18next-fs-backend, and i18next-http-backend. Create src/shared/i18n/config.ts with initI18n() function supporting 9 languages and 6 namespaces. Add English base translation files with skeleton keys for common, settings, modals, menus, notifications, and accessibility namespaces. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 169 +++++++++++++++++- package.json | 5 + src/shared/i18n/config.ts | 90 ++++++++++ src/shared/i18n/locales/en/accessibility.json | 1 + src/shared/i18n/locales/en/common.json | 35 ++++ src/shared/i18n/locales/en/menus.json | 1 + src/shared/i18n/locales/en/modals.json | 1 + src/shared/i18n/locales/en/notifications.json | 1 + src/shared/i18n/locales/en/settings.json | 1 + 9 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 src/shared/i18n/config.ts create mode 100644 src/shared/i18n/locales/en/accessibility.json create mode 100644 src/shared/i18n/locales/en/common.json create mode 100644 src/shared/i18n/locales/en/menus.json create mode 100644 src/shared/i18n/locales/en/modals.json create mode 100644 src/shared/i18n/locales/en/notifications.json create mode 100644 src/shared/i18n/locales/en/settings.json diff --git a/package-lock.json b/package-lock.json index 7482623e10..e6be21b10b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -35,6 +35,10 @@ "electron-store": "^8.1.0", "electron-updater": "^6.6.2", "fastify": "^4.25.2", + "i18next": "^25.8.17", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-fs-backend": "^2.6.1", + "i18next-http-backend": "^3.0.2", "js-tiktoken": "^1.0.21", "marked": "^17.0.1", "mermaid": "^11.12.1", @@ -42,6 +46,7 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", + "react-i18next": "^16.5.6", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", @@ -468,9 +473,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7131,6 +7136,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8896,7 +8910,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10950,6 +10963,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -11062,6 +11084,61 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "25.8.17", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.17.tgz", + "integrity": "sha512-vWtCttyn5bpOK4hWbRAe1ZXkA+Yzcn2OcACT+WJavtfGMcxzkfvXTLMeOU8MUhRmAySKjU4VVuKlo0sSGeBokA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.1.tgz", + "integrity": "sha512-eYWTX7QT7kJ0sZyCPK6x1q+R63zvNKv2D6UdbMf15A8vNb2ZLyw4NNNZxPFwXlIv/U+oUtg8SakW6ZgJZcoqHQ==", + "license": "MIT" + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -14205,6 +14282,48 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", @@ -15703,6 +15822,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.6.tgz", + "integrity": "sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -17993,7 +18139,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19545,6 +19691,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", diff --git a/package.json b/package.json index 73cbab52aa..722feede8b 100644 --- a/package.json +++ b/package.json @@ -240,6 +240,10 @@ "electron-store": "^8.1.0", "electron-updater": "^6.6.2", "fastify": "^4.25.2", + "i18next": "^25.8.17", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-fs-backend": "^2.6.1", + "i18next-http-backend": "^3.0.2", "js-tiktoken": "^1.0.21", "marked": "^17.0.1", "mermaid": "^11.12.1", @@ -247,6 +251,7 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", + "react-i18next": "^16.5.6", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", diff --git a/src/shared/i18n/config.ts b/src/shared/i18n/config.ts new file mode 100644 index 0000000000..c6f4c439cc --- /dev/null +++ b/src/shared/i18n/config.ts @@ -0,0 +1,90 @@ +/** + * i18n Configuration + * + * Initializes i18next with react-i18next for internationalization support. + * Uses browser language detector for automatic locale detection and + * bundled JSON resources for translation strings. + * + * Supported languages: en, es, fr, de, zh, hi, ar, bn, pt + * Namespaces: common, settings, modals, menus, notifications, accessibility + */ + +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +// Import English base translations (bundled at build time) +import commonEn from './locales/en/common.json'; +import settingsEn from './locales/en/settings.json'; +import modalsEn from './locales/en/modals.json'; +import menusEn from './locales/en/menus.json'; +import notificationsEn from './locales/en/notifications.json'; +import accessibilityEn from './locales/en/accessibility.json'; + +export const SUPPORTED_LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +export const I18N_NAMESPACES = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', +] as const; +export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; + +/** localStorage key used to persist the user's language preference */ +export const LANGUAGE_STORAGE_KEY = 'maestro-language'; + +/** RTL languages in our supported set */ +export const RTL_LANGUAGES: SupportedLanguage[] = ['ar']; + +/** + * Initialize i18next with all plugins and configuration. + * Returns a promise that resolves when i18n is ready. + * + * English translations are bundled directly; other languages + * will be lazy-loaded in future phases. + */ +export function initI18n(): Promise { + return i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + // Bundled resources — English loaded at startup, others added lazily + resources: { + en: { + common: commonEn, + settings: settingsEn, + modals: modalsEn, + menus: menusEn, + notifications: notificationsEn, + accessibility: accessibilityEn, + }, + }, + + fallbackLng: 'en', + supportedLngs: [...SUPPORTED_LANGUAGES], + + ns: [...I18N_NAMESPACES], + defaultNS: 'common', + + interpolation: { + escapeValue: false, // React already escapes rendered output + }, + + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: LANGUAGE_STORAGE_KEY, + }, + + // Don't suspend on missing translations — fall back to English + react: { + useSuspense: false, + }, + }) + .then(() => i18n); +} + +export default i18n; diff --git a/src/shared/i18n/locales/en/accessibility.json b/src/shared/i18n/locales/en/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/en/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/en/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/en/menus.json b/src/shared/i18n/locales/en/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/en/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/en/modals.json b/src/shared/i18n/locales/en/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/en/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/en/notifications.json b/src/shared/i18n/locales/en/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/en/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/en/settings.json @@ -0,0 +1 @@ +{} From 3a04225e573c02bea1017ed5aab8127447a0f578 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 00:49:03 -0400 Subject: [PATCH 02/92] MAESTRO: add locale directory structure for 8 non-English languages Create placeholder translation files (copied from English base) for es, fr, de, zh, hi, ar, bn, pt. Each directory contains all 6 namespace files (common, settings, modals, menus, notifications, accessibility). These will be translated in I18N-11. Co-Authored-By: Claude Opus 4.6 --- src/shared/i18n/locales/ar/accessibility.json | 1 + src/shared/i18n/locales/ar/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/ar/menus.json | 1 + src/shared/i18n/locales/ar/modals.json | 1 + src/shared/i18n/locales/ar/notifications.json | 1 + src/shared/i18n/locales/ar/settings.json | 1 + src/shared/i18n/locales/bn/accessibility.json | 1 + src/shared/i18n/locales/bn/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/bn/menus.json | 1 + src/shared/i18n/locales/bn/modals.json | 1 + src/shared/i18n/locales/bn/notifications.json | 1 + src/shared/i18n/locales/bn/settings.json | 1 + src/shared/i18n/locales/de/accessibility.json | 1 + src/shared/i18n/locales/de/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/de/menus.json | 1 + src/shared/i18n/locales/de/modals.json | 1 + src/shared/i18n/locales/de/notifications.json | 1 + src/shared/i18n/locales/de/settings.json | 1 + src/shared/i18n/locales/es/accessibility.json | 1 + src/shared/i18n/locales/es/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/es/menus.json | 1 + src/shared/i18n/locales/es/modals.json | 1 + src/shared/i18n/locales/es/notifications.json | 1 + src/shared/i18n/locales/es/settings.json | 1 + src/shared/i18n/locales/fr/accessibility.json | 1 + src/shared/i18n/locales/fr/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/fr/menus.json | 1 + src/shared/i18n/locales/fr/modals.json | 1 + src/shared/i18n/locales/fr/notifications.json | 1 + src/shared/i18n/locales/fr/settings.json | 1 + src/shared/i18n/locales/hi/accessibility.json | 1 + src/shared/i18n/locales/hi/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/hi/menus.json | 1 + src/shared/i18n/locales/hi/modals.json | 1 + src/shared/i18n/locales/hi/notifications.json | 1 + src/shared/i18n/locales/hi/settings.json | 1 + src/shared/i18n/locales/pt/accessibility.json | 1 + src/shared/i18n/locales/pt/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/pt/menus.json | 1 + src/shared/i18n/locales/pt/modals.json | 1 + src/shared/i18n/locales/pt/notifications.json | 1 + src/shared/i18n/locales/pt/settings.json | 1 + src/shared/i18n/locales/zh/accessibility.json | 1 + src/shared/i18n/locales/zh/common.json | 35 +++++++++++++++++++ src/shared/i18n/locales/zh/menus.json | 1 + src/shared/i18n/locales/zh/modals.json | 1 + src/shared/i18n/locales/zh/notifications.json | 1 + src/shared/i18n/locales/zh/settings.json | 1 + 48 files changed, 320 insertions(+) create mode 100644 src/shared/i18n/locales/ar/accessibility.json create mode 100644 src/shared/i18n/locales/ar/common.json create mode 100644 src/shared/i18n/locales/ar/menus.json create mode 100644 src/shared/i18n/locales/ar/modals.json create mode 100644 src/shared/i18n/locales/ar/notifications.json create mode 100644 src/shared/i18n/locales/ar/settings.json create mode 100644 src/shared/i18n/locales/bn/accessibility.json create mode 100644 src/shared/i18n/locales/bn/common.json create mode 100644 src/shared/i18n/locales/bn/menus.json create mode 100644 src/shared/i18n/locales/bn/modals.json create mode 100644 src/shared/i18n/locales/bn/notifications.json create mode 100644 src/shared/i18n/locales/bn/settings.json create mode 100644 src/shared/i18n/locales/de/accessibility.json create mode 100644 src/shared/i18n/locales/de/common.json create mode 100644 src/shared/i18n/locales/de/menus.json create mode 100644 src/shared/i18n/locales/de/modals.json create mode 100644 src/shared/i18n/locales/de/notifications.json create mode 100644 src/shared/i18n/locales/de/settings.json create mode 100644 src/shared/i18n/locales/es/accessibility.json create mode 100644 src/shared/i18n/locales/es/common.json create mode 100644 src/shared/i18n/locales/es/menus.json create mode 100644 src/shared/i18n/locales/es/modals.json create mode 100644 src/shared/i18n/locales/es/notifications.json create mode 100644 src/shared/i18n/locales/es/settings.json create mode 100644 src/shared/i18n/locales/fr/accessibility.json create mode 100644 src/shared/i18n/locales/fr/common.json create mode 100644 src/shared/i18n/locales/fr/menus.json create mode 100644 src/shared/i18n/locales/fr/modals.json create mode 100644 src/shared/i18n/locales/fr/notifications.json create mode 100644 src/shared/i18n/locales/fr/settings.json create mode 100644 src/shared/i18n/locales/hi/accessibility.json create mode 100644 src/shared/i18n/locales/hi/common.json create mode 100644 src/shared/i18n/locales/hi/menus.json create mode 100644 src/shared/i18n/locales/hi/modals.json create mode 100644 src/shared/i18n/locales/hi/notifications.json create mode 100644 src/shared/i18n/locales/hi/settings.json create mode 100644 src/shared/i18n/locales/pt/accessibility.json create mode 100644 src/shared/i18n/locales/pt/common.json create mode 100644 src/shared/i18n/locales/pt/menus.json create mode 100644 src/shared/i18n/locales/pt/modals.json create mode 100644 src/shared/i18n/locales/pt/notifications.json create mode 100644 src/shared/i18n/locales/pt/settings.json create mode 100644 src/shared/i18n/locales/zh/accessibility.json create mode 100644 src/shared/i18n/locales/zh/common.json create mode 100644 src/shared/i18n/locales/zh/menus.json create mode 100644 src/shared/i18n/locales/zh/modals.json create mode 100644 src/shared/i18n/locales/zh/notifications.json create mode 100644 src/shared/i18n/locales/zh/settings.json diff --git a/src/shared/i18n/locales/ar/accessibility.json b/src/shared/i18n/locales/ar/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/ar/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/ar/common.json b/src/shared/i18n/locales/ar/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/ar/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/ar/menus.json b/src/shared/i18n/locales/ar/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/ar/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/ar/modals.json b/src/shared/i18n/locales/ar/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/ar/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/ar/notifications.json b/src/shared/i18n/locales/ar/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/ar/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/ar/settings.json b/src/shared/i18n/locales/ar/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/ar/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/bn/accessibility.json b/src/shared/i18n/locales/bn/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/bn/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/bn/common.json b/src/shared/i18n/locales/bn/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/bn/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/bn/menus.json b/src/shared/i18n/locales/bn/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/bn/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/bn/modals.json b/src/shared/i18n/locales/bn/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/bn/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/bn/notifications.json b/src/shared/i18n/locales/bn/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/bn/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/bn/settings.json b/src/shared/i18n/locales/bn/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/bn/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/de/accessibility.json b/src/shared/i18n/locales/de/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/de/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/de/common.json b/src/shared/i18n/locales/de/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/de/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/de/menus.json b/src/shared/i18n/locales/de/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/de/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/de/modals.json b/src/shared/i18n/locales/de/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/de/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/de/notifications.json b/src/shared/i18n/locales/de/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/de/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/de/settings.json b/src/shared/i18n/locales/de/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/de/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/es/accessibility.json b/src/shared/i18n/locales/es/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/es/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/es/common.json b/src/shared/i18n/locales/es/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/es/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/es/menus.json b/src/shared/i18n/locales/es/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/es/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/es/modals.json b/src/shared/i18n/locales/es/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/es/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/es/notifications.json b/src/shared/i18n/locales/es/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/es/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/es/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/fr/accessibility.json b/src/shared/i18n/locales/fr/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/fr/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/fr/common.json b/src/shared/i18n/locales/fr/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/fr/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/fr/menus.json b/src/shared/i18n/locales/fr/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/fr/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/fr/modals.json b/src/shared/i18n/locales/fr/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/fr/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/fr/notifications.json b/src/shared/i18n/locales/fr/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/fr/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/fr/settings.json b/src/shared/i18n/locales/fr/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/fr/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/hi/accessibility.json b/src/shared/i18n/locales/hi/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/hi/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/hi/common.json b/src/shared/i18n/locales/hi/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/hi/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/hi/menus.json b/src/shared/i18n/locales/hi/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/hi/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/hi/modals.json b/src/shared/i18n/locales/hi/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/hi/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/hi/notifications.json b/src/shared/i18n/locales/hi/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/hi/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/hi/settings.json b/src/shared/i18n/locales/hi/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/hi/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/pt/accessibility.json b/src/shared/i18n/locales/pt/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/pt/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/pt/common.json b/src/shared/i18n/locales/pt/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/pt/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/pt/menus.json b/src/shared/i18n/locales/pt/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/pt/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/pt/modals.json b/src/shared/i18n/locales/pt/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/pt/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/pt/notifications.json b/src/shared/i18n/locales/pt/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/pt/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/pt/settings.json b/src/shared/i18n/locales/pt/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/pt/settings.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/zh/accessibility.json b/src/shared/i18n/locales/zh/accessibility.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/zh/accessibility.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/zh/common.json b/src/shared/i18n/locales/zh/common.json new file mode 100644 index 0000000000..94cc07b228 --- /dev/null +++ b/src/shared/i18n/locales/zh/common.json @@ -0,0 +1,35 @@ +{ + "save": "Save", + "cancel": "Cancel", + "close": "Close", + "delete": "Delete", + "confirm": "Confirm", + "loading": "Loading", + "error": "Error", + "success": "Success", + "search": "Search", + "copy": "Copy", + "paste": "Paste", + "undo": "Undo", + "redo": "Redo", + "yes": "Yes", + "no": "No", + "ok": "OK", + "back": "Back", + "next": "Next", + "done": "Done", + "retry": "Retry", + "reset": "Reset", + "clear": "Clear", + "apply": "Apply", + "edit": "Edit", + "remove": "Remove", + "add": "Add", + "create": "Create", + "open": "Open", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "settings": "Settings", + "help": "Help" +} diff --git a/src/shared/i18n/locales/zh/menus.json b/src/shared/i18n/locales/zh/menus.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/zh/menus.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/zh/modals.json b/src/shared/i18n/locales/zh/modals.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/zh/modals.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/zh/notifications.json b/src/shared/i18n/locales/zh/notifications.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/zh/notifications.json @@ -0,0 +1 @@ +{} diff --git a/src/shared/i18n/locales/zh/settings.json b/src/shared/i18n/locales/zh/settings.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/shared/i18n/locales/zh/settings.json @@ -0,0 +1 @@ +{} From 27df86eb7bf3391bfd190663fdfc1bb618a80671 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 00:53:40 -0400 Subject: [PATCH 03/92] MAESTRO: add locale detection IPC bridge with system language mapping Adds locale:get-system and locale:set IPC handlers that detect system locale via Electron's app.getLocale() and app.getPreferredSystemLanguages(), map BCP 47 tags to supported language codes, and persist user preference. Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 7 +++ src/main/ipc/handlers/index.ts | 3 ++ src/main/ipc/handlers/locale.ts | 93 +++++++++++++++++++++++++++++++++ src/main/preload/index.ts | 10 ++++ src/main/preload/locale.ts | 33 ++++++++++++ src/renderer/global.d.ts | 8 +++ 6 files changed, 154 insertions(+) create mode 100644 src/main/ipc/handlers/locale.ts create mode 100644 src/main/preload/locale.ts diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530f..f287828240 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,6 +54,7 @@ import { registerAgentErrorHandlers, registerDirectorNotesHandlers, registerWakatimeHandlers, + registerLocaleHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, @@ -685,6 +686,12 @@ function setupIpcHandlers() { // Register WakaTime handlers (CLI check, API key validation) registerWakatimeHandlers(wakatimeManager); + + // Register Locale handlers (system locale detection and language preference) + registerLocaleHandlers({ + app, + settingsStore: store, + }); } // Handle process output streaming (set up after initialization) diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e8053..def7b2b851 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error'; import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming'; import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes'; import { registerWakatimeHandlers } from './wakatime'; +import { registerLocaleHandlers, LocaleHandlerDependencies } from './locale'; import { AgentDetector } from '../../agents'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -97,6 +98,8 @@ export type { TabNamingHandlerDependencies }; export { registerDirectorNotesHandlers }; export type { DirectorNotesHandlerDependencies }; export { registerWakatimeHandlers }; +export { registerLocaleHandlers }; +export type { LocaleHandlerDependencies }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; diff --git a/src/main/ipc/handlers/locale.ts b/src/main/ipc/handlers/locale.ts new file mode 100644 index 0000000000..473fc09d41 --- /dev/null +++ b/src/main/ipc/handlers/locale.ts @@ -0,0 +1,93 @@ +/** + * IPC Handlers for locale detection and language preference + * + * Provides system locale detection via Electron's app.getLocale() and + * app.getPreferredSystemLanguages(), plus user language preference persistence. + */ + +import { ipcMain, App } from 'electron'; +import Store from 'electron-store'; +import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../../../shared/i18n/config'; +import { logger } from '../../utils/logger'; + +export interface LocaleHandlerDependencies { + app: App; + settingsStore: Store; +} + +/** + * Map a BCP 47 language tag (e.g., 'zh-CN', 'pt-BR', 'en-US') to one of + * our supported language codes. Returns undefined if no match is found. + */ +function mapToSupportedLanguage(tag: string): SupportedLanguage | undefined { + const lower = tag.toLowerCase(); + + // Try exact match first + if ((SUPPORTED_LANGUAGES as readonly string[]).includes(lower)) { + return lower as SupportedLanguage; + } + + // Extract the primary language subtag (before the first hyphen) + const primary = lower.split('-')[0]; + if ((SUPPORTED_LANGUAGES as readonly string[]).includes(primary)) { + return primary as SupportedLanguage; + } + + return undefined; +} + +/** + * Detect the best supported language from Electron's system locale APIs. + * Tries app.getPreferredSystemLanguages() first (ordered by user preference), + * then falls back to app.getLocale(), and finally to 'en'. + */ +function getSystemLocale(app: App): SupportedLanguage { + // Try preferred system languages first (ordered by user preference) + try { + const preferred = app.getPreferredSystemLanguages(); + for (const lang of preferred) { + const mapped = mapToSupportedLanguage(lang); + if (mapped) { + logger.debug(`System locale detected: ${mapped} (from preferred: ${lang})`, 'Locale'); + return mapped; + } + } + } catch { + // getPreferredSystemLanguages may not be available on all platforms + } + + // Fall back to app.getLocale() + const locale = app.getLocale(); + const mapped = mapToSupportedLanguage(locale); + if (mapped) { + logger.debug(`System locale detected: ${mapped} (from app.getLocale: ${locale})`, 'Locale'); + return mapped; + } + + logger.debug(`No supported locale found, falling back to 'en'`, 'Locale'); + return 'en'; +} + +export function registerLocaleHandlers(deps: LocaleHandlerDependencies): void { + const { app, settingsStore } = deps; + + /** + * locale:get-system — Returns the detected system locale mapped to a supported language code. + */ + ipcMain.handle('locale:get-system', async () => { + return getSystemLocale(app); + }); + + /** + * locale:set — Stores the user's language preference in the settings store. + */ + ipcMain.handle('locale:set', async (_, language: string) => { + if (!(SUPPORTED_LANGUAGES as readonly string[]).includes(language)) { + logger.warn(`Attempted to set unsupported language: ${language}`, 'Locale'); + return { success: false, error: `Unsupported language: ${language}` }; + } + settingsStore.set('language', language); + logger.info(`Language preference set to: ${language}`, 'Locale'); + return { success: true }; + }); +} diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f81..10165634cd 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -50,6 +50,7 @@ import { createSymphonyApi } from './symphony'; import { createTabNamingApi } from './tabNaming'; import { createDirectorNotesApi } from './directorNotes'; import { createWakatimeApi } from './wakatime'; +import { createLocaleApi } from './locale'; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object @@ -191,6 +192,9 @@ contextBridge.exposeInMainWorld('maestro', { // WakaTime API (CLI check, API key validation) wakatime: createWakatimeApi(), + + // Locale API (system locale detection and language preference) + locale: createLocaleApi(), }); // Re-export factory functions for external consumers (e.g., tests) @@ -264,6 +268,8 @@ export { createDirectorNotesApi, // WakaTime createWakatimeApi, + // Locale + createLocaleApi, }; // Re-export types for TypeScript consumers @@ -472,3 +478,7 @@ export type { // From wakatime WakatimeApi, } from './wakatime'; +export type { + // From locale + LocaleApi, +} from './locale'; diff --git a/src/main/preload/locale.ts b/src/main/preload/locale.ts new file mode 100644 index 0000000000..45721805b5 --- /dev/null +++ b/src/main/preload/locale.ts @@ -0,0 +1,33 @@ +/** + * Preload API for locale detection and language preference + * + * Provides the window.maestro.locale namespace for: + * - Detecting system locale from the main process + * - Persisting the user's language preference + */ + +import { ipcRenderer } from 'electron'; + +/** + * Creates the locale API object for preload exposure + */ +export function createLocaleApi() { + return { + /** + * Get the detected system locale mapped to a supported language code. + * Uses Electron's app.getLocale() and app.getPreferredSystemLanguages(). + * @returns A supported language code (e.g., 'en', 'zh', 'pt') + */ + getSystem: (): Promise => ipcRenderer.invoke('locale:get-system'), + + /** + * Store the user's language preference in the settings store. + * @param language - A supported language code + * @returns Success/error result + */ + set: (language: string): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('locale:set', language), + }; +} + +export type LocaleApi = ReturnType; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index f2859a992f..5163241814 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2713,6 +2713,14 @@ interface MaestroAPI { checkCli: () => Promise<{ available: boolean; version?: string }>; validateApiKey: (key: string) => Promise<{ valid: boolean }>; }; + + // Locale API (system locale detection and language preference) + locale: { + /** Get the detected system locale mapped to a supported language code */ + getSystem: () => Promise; + /** Store the user's language preference */ + set: (language: string) => Promise<{ success: boolean; error?: string }>; + }; } declare global { From b506c5e7d2dcba908cdf2d988c4e8624090484e9 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 01:17:44 -0400 Subject: [PATCH 04/92] MAESTRO: integrate i18n initialization into React renderer entry point Calls initI18n() before ReactDOM render, wraps app tree in Suspense, and detects system locale via IPC when no stored preference exists. Co-Authored-By: Claude Opus 4.6 --- src/renderer/main.tsx | 49 ++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 78d6140d59..d54fe96df8 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,6 +1,6 @@ // IMPORTANT: wdyr must be imported BEFORE React import './wdyr'; -import React from 'react'; +import React, { Suspense } from 'react'; import ReactDOM from 'react-dom/client'; import * as Sentry from '@sentry/electron/renderer'; import MaestroConsole from './App'; @@ -10,6 +10,7 @@ import { LayerStackProvider } from './contexts/LayerStackContext'; // ModalProvider removed - modal state now managed by modalStore (Zustand) import { WizardProvider } from './components/Wizard'; import { logger } from './utils/logger'; +import { initI18n, LANGUAGE_STORAGE_KEY } from '../shared/i18n/config'; import './index.css'; // Initialize Sentry for renderer process @@ -84,14 +85,38 @@ window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => event.preventDefault(); }); -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - -); +// Initialize i18n before rendering. +// If the user has no stored preference, detect the system locale via IPC +// and set it as the initial language. +const bootstrap = async () => { + const i18n = await initI18n(); + + // Check for a stored user preference; if absent, detect from OS + const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY); + if (!stored) { + try { + const systemLocale = await window.maestro?.locale?.getSystem(); + if (systemLocale && systemLocale !== i18n.language) { + await i18n.changeLanguage(systemLocale); + } + } catch { + // IPC not available (e.g. tests) — keep fallback 'en' + } + } + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + ); +}; + +bootstrap(); From 6b5de6308872112b45da8e594c86ade3f195f7b0 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 01:31:53 -0400 Subject: [PATCH 05/92] MAESTRO: add language setting to settings store with RTL and document attribute support Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/settings/useSettings.ts | 16 ++++++++++++++++ src/renderer/stores/settingsStore.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index ef91878b82..c877718005 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -33,6 +33,8 @@ import { selectIsLeaderboardRegistered, } from '../../stores/settingsStore'; import type { DocumentGraphLayoutType } from '../../stores/settingsStore'; +import { RTL_LANGUAGES } from '../../../shared/i18n/config'; +import type { SupportedLanguage } from '../../../shared/i18n/config'; export interface UseSettingsReturn { // Loading state @@ -229,6 +231,10 @@ export interface UseSettingsReturn { colorBlindMode: boolean; setColorBlindMode: (value: boolean) => void; + // Language / i18n + language: string; + setLanguage: (value: string) => void; + // Document Graph settings documentGraphShowExternalLinks: boolean; setDocumentGraphShowExternalLinks: (value: boolean) => void; @@ -339,6 +345,16 @@ export function useSettings(): UseSettingsReturn { } }, [store.fontSize, store.settingsLoaded]); + // Apply language attributes to HTML root element for i18n and RTL support + useEffect(() => { + if (store.settingsLoaded) { + document.documentElement.lang = store.language; + document.documentElement.dir = RTL_LANGUAGES.includes(store.language as SupportedLanguage) + ? 'rtl' + : 'ltr'; + } + }, [store.language, store.settingsLoaded]); + return { ...store, isLeaderboardRegistered, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 0400cfb006..ebe23f7f33 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -35,6 +35,8 @@ import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes'; import { DEFAULT_SHORTCUTS, TAB_SHORTCUTS, FIXED_SHORTCUTS } from '../constants/shortcuts'; import { getLevelIndex } from '../constants/keyboardMastery'; import { commitCommandPrompt } from '../../prompts'; +import i18n, { LANGUAGE_STORAGE_KEY, RTL_LANGUAGES } from '../../shared/i18n/config'; +import type { SupportedLanguage } from '../../shared/i18n/config'; // ============================================================================ // Shared Type Aliases @@ -226,6 +228,7 @@ export interface SettingsStoreState { contextManagementSettings: ContextManagementSettings; keyboardMasteryStats: KeyboardMasteryStats; colorBlindMode: boolean; + language: string; documentGraphShowExternalLinks: boolean; documentGraphMaxNodes: number; documentGraphPreviewCharLimit: number; @@ -298,6 +301,7 @@ export interface SettingsStoreActions { setWebInterfaceUseCustomPort: (value: boolean) => void; setWebInterfaceCustomPort: (value: number) => void; setColorBlindMode: (value: boolean) => void; + setLanguage: (value: string) => void; setDocumentGraphShowExternalLinks: (value: boolean) => void; setDocumentGraphMaxNodes: (value: number) => void; setDocumentGraphPreviewCharLimit: (value: number) => void; @@ -444,6 +448,7 @@ export const useSettingsStore = create()((set, get) => ({ contextManagementSettings: DEFAULT_CONTEXT_MANAGEMENT_SETTINGS, keyboardMasteryStats: DEFAULT_KEYBOARD_MASTERY_STATS, colorBlindMode: false, + language: 'en', documentGraphShowExternalLinks: false, documentGraphMaxNodes: 50, documentGraphPreviewCharLimit: 100, @@ -695,6 +700,17 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('colorBlindMode', value); }, + setLanguage: (value) => { + set({ language: value }); + window.maestro.settings.set('language', value); + localStorage.setItem(LANGUAGE_STORAGE_KEY, value); + i18n.changeLanguage(value); + document.documentElement.lang = value; + document.documentElement.dir = RTL_LANGUAGES.includes(value as SupportedLanguage) + ? 'rtl' + : 'ltr'; + }, + setDocumentGraphShowExternalLinks: (value) => { set({ documentGraphShowExternalLinks: value }); window.maestro.settings.set('documentGraphShowExternalLinks', value); @@ -1599,6 +1615,8 @@ export async function loadAllSettings(): Promise { if (allSettings['colorBlindMode'] !== undefined) patch.colorBlindMode = allSettings['colorBlindMode'] as boolean; + if (allSettings['language'] !== undefined) patch.language = allSettings['language'] as string; + // Document Graph settings (with validation) if (allSettings['documentGraphShowExternalLinks'] !== undefined) patch.documentGraphShowExternalLinks = allSettings[ From 339a16b8f5a52f3410d5ce586ef4d9b78335e59c Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 01:38:20 -0400 Subject: [PATCH 06/92] MAESTRO: add useI18n hook, i18n types, and i18next module augmentation for typed translations Co-Authored-By: Claude Opus 4.6 --- src/renderer/hooks/useI18n.ts | 17 +++++++++++++++++ src/shared/i18n/resources.d.ts | 17 +++++++++++++++++ src/shared/i18n/types.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/renderer/hooks/useI18n.ts create mode 100644 src/shared/i18n/resources.d.ts create mode 100644 src/shared/i18n/types.ts diff --git a/src/renderer/hooks/useI18n.ts b/src/renderer/hooks/useI18n.ts new file mode 100644 index 0000000000..30dcab7b11 --- /dev/null +++ b/src/renderer/hooks/useI18n.ts @@ -0,0 +1,17 @@ +/** + * useI18n — Typed convenience wrapper around react-i18next's useTranslation. + * + * Re-exports useTranslation with Maestro's namespace types so consumers + * get autocompletion on namespace names and translation keys. + * + * Usage: + * const { t } = useI18n(); // defaults to 'common' namespace + * const { t } = useI18n('settings'); // specific namespace + */ + +import { useTranslation } from 'react-i18next'; +import type { I18nNamespace } from '../../shared/i18n/config'; + +export function useI18n(ns?: I18nNamespace | I18nNamespace[]) { + return useTranslation(ns); +} diff --git a/src/shared/i18n/resources.d.ts b/src/shared/i18n/resources.d.ts new file mode 100644 index 0000000000..dc6d5425e1 --- /dev/null +++ b/src/shared/i18n/resources.d.ts @@ -0,0 +1,17 @@ +/** + * i18next Module Augmentation + * + * Declares the resource types so that t('common:save') and + * similar calls get autocompletion and type checking. + * + * @see https://www.i18next.com/overview/typescript + */ + +import type { I18nResources } from './types'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'common'; + resources: I18nResources; + } +} diff --git a/src/shared/i18n/types.ts b/src/shared/i18n/types.ts new file mode 100644 index 0000000000..75986096ea --- /dev/null +++ b/src/shared/i18n/types.ts @@ -0,0 +1,28 @@ +/** + * i18n Type Definitions + * + * Provides typed namespace unions and key helpers for type-safe translations. + * Used alongside resources.d.ts to enable autocompletion on t() calls. + */ + +import type { I18nNamespace } from './config'; + +import type commonEn from './locales/en/common.json'; +import type settingsEn from './locales/en/settings.json'; +import type modalsEn from './locales/en/modals.json'; +import type menusEn from './locales/en/menus.json'; +import type notificationsEn from './locales/en/notifications.json'; +import type accessibilityEn from './locales/en/accessibility.json'; + +/** Map each namespace to its translation key set (derived from English base files) */ +export interface I18nResources { + common: typeof commonEn; + settings: typeof settingsEn; + modals: typeof modalsEn; + menus: typeof menusEn; + notifications: typeof notificationsEn; + accessibility: typeof accessibilityEn; +} + +/** Extract valid translation keys for a given namespace */ +export type TranslationKey = keyof I18nResources[NS] & string; From 6d092879c962d956cb3febc939ba515103f5ab2f Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 01:52:04 -0400 Subject: [PATCH 07/92] MAESTRO: refactor formatRelativeTime() to locale-aware Intl.RelativeTimeFormat Add getActiveLocale() helper that reads from the i18n instance for auto-detecting the user's language. Refactor formatRelativeTime() to use Intl.RelativeTimeFormat for localized relative time output (e.g., "5 minutes ago" in English, "hace 5 minutos" in Spanish). Add optional locale parameter for manual override. Update all 8 affected test files to match new output format and add i18n config mocks. Co-Authored-By: Claude Opus 4.6 --- .../components/AgentSessionsBrowser.test.tsx | 14 ++-- .../components/AgentSessionsModal.test.tsx | 21 +++--- .../components/TabSwitcherModal.test.tsx | 14 ++-- src/__tests__/shared/formatters.test.ts | 65 ++++++++++++++----- .../web/mobile/CommandHistoryDrawer.test.tsx | 24 ++++--- .../web/mobile/OfflineQueueBanner.test.tsx | 61 ++++++++--------- .../web/mobile/SessionStatusBanner.test.tsx | 26 ++++---- src/shared/formatters.ts | 53 +++++++++++---- 8 files changed, 177 insertions(+), 101 deletions(-) diff --git a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx index a700fe94fa..23ddc76ba1 100644 --- a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx @@ -51,6 +51,10 @@ vi.mock('lucide-react', () => ({ AlertCircle: () => , })); +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + // Default theme const defaultTheme: Theme = { id: 'dracula', @@ -381,7 +385,7 @@ describe('AgentSessionsBrowser', () => { }); describe('formatRelativeTime helper', () => { - it('formats just now correctly', async () => { + it('formats "now" correctly', async () => { const now = new Date(); const session = createMockClaudeSession({ modifiedAt: now.toISOString(), @@ -398,7 +402,7 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); - expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); it('formats minutes ago correctly', async () => { @@ -418,7 +422,7 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); - expect(screen.getByText('30m ago')).toBeInTheDocument(); + expect(screen.getByText('30 minutes ago')).toBeInTheDocument(); }); it('formats hours ago correctly', async () => { @@ -438,7 +442,7 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); - expect(screen.getByText('5h ago')).toBeInTheDocument(); + expect(screen.getByText('5 hours ago')).toBeInTheDocument(); }); it('formats days ago correctly', async () => { @@ -458,7 +462,7 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); - expect(screen.getByText('3d ago')).toBeInTheDocument(); + expect(screen.getByText('3 days ago')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx index cf594c6fd3..4bd23f8f3e 100644 --- a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx @@ -23,6 +23,11 @@ vi.mock('../../../renderer/constants/modalPriorities', () => ({ }, })); +// Mock i18n config +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + // Create a mock theme const mockTheme: Theme = { id: 'dracula', @@ -566,7 +571,7 @@ describe('AgentSessionsModal', () => { }); describe('Relative Time Formatting', () => { - it('should display "just now" for recent timestamps', async () => { + it('should display "now" for recent timestamps', async () => { const mockSessions = [createMockClaudeSession({ modifiedAt: new Date().toISOString() })]; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions: mockSessions, @@ -585,7 +590,7 @@ describe('AgentSessionsModal', () => { ); await waitFor(() => { - expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); }); @@ -610,7 +615,7 @@ describe('AgentSessionsModal', () => { ); await waitFor(() => { - expect(screen.getByText('15m ago')).toBeInTheDocument(); + expect(screen.getByText('15 minutes ago')).toBeInTheDocument(); }); }); @@ -635,13 +640,13 @@ describe('AgentSessionsModal', () => { ); await waitFor(() => { - expect(screen.getByText('5h ago')).toBeInTheDocument(); + expect(screen.getByText('5 hours ago')).toBeInTheDocument(); }); }); it('should display days ago', async () => { - // Use explicit ms offset to avoid DST boundary issues with calendar-day subtraction - const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000 - 60000); + // Use exact millisecond offset to avoid DST boundary issues with setDate() + const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); const mockSessions = [createMockClaudeSession({ modifiedAt: date.toISOString() })]; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions: mockSessions, @@ -660,7 +665,7 @@ describe('AgentSessionsModal', () => { ); await waitFor(() => { - expect(screen.getByText('3d ago')).toBeInTheDocument(); + expect(screen.getByText('3 days ago')).toBeInTheDocument(); }); }); @@ -1210,7 +1215,7 @@ describe('AgentSessionsModal', () => { await waitFor(() => { expect(screen.getByText(/5 messages/)).toBeInTheDocument(); - expect(screen.getByText(/just now/)).toBeInTheDocument(); + expect(screen.getByText(/\bnow\b/)).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx index b671a6cf6b..4a4ec6ace0 100644 --- a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx +++ b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx @@ -26,6 +26,10 @@ vi.mock('lucide-react', () => ({ FileText: () => , })); +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + // Create a test theme const createTestTheme = (overrides: Partial = {}): Theme => ({ id: 'test-theme', @@ -265,7 +269,7 @@ describe('TabSwitcherModal', () => { }); describe('formatRelativeTime', () => { - it('formats "just now" for < 1 minute ago', () => { + it('formats "now" for < 1 minute ago', () => { const tab = createTestTab({ logs: [{ id: '1', timestamp: Date.now() - 30000, source: 'stdout', text: 'test' }], }); @@ -282,7 +286,7 @@ describe('TabSwitcherModal', () => { /> ); - expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); it('formats minutes ago', () => { @@ -304,7 +308,7 @@ describe('TabSwitcherModal', () => { /> ); - expect(screen.getByText('5m ago')).toBeInTheDocument(); + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); }); it('formats hours ago', () => { @@ -326,7 +330,7 @@ describe('TabSwitcherModal', () => { /> ); - expect(screen.getByText('3h ago')).toBeInTheDocument(); + expect(screen.getByText('3 hours ago')).toBeInTheDocument(); }); it('formats days ago', () => { @@ -353,7 +357,7 @@ describe('TabSwitcherModal', () => { /> ); - expect(screen.getByText('2d ago')).toBeInTheDocument(); + expect(screen.getByText('2 days ago')).toBeInTheDocument(); }); it('formats as date for > 7 days ago', () => { diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index da10c65bfd..fece78bdfa 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -16,8 +16,14 @@ import { estimateTokenCount, truncatePath, truncateCommand, + getActiveLocale, } from '../../shared/formatters'; +// Mock i18n to avoid initializing the full i18n stack in tests +vi.mock('../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + describe('shared/formatters', () => { // ========================================================================== // formatSize tests @@ -131,32 +137,46 @@ describe('shared/formatters', () => { }); // ========================================================================== - // formatRelativeTime tests + // getActiveLocale tests + // ========================================================================== + describe('getActiveLocale', () => { + it('should return override locale when provided', () => { + expect(getActiveLocale('fr')).toBe('fr'); + expect(getActiveLocale('es')).toBe('es'); + }); + + it('should return i18n language when no override', () => { + expect(getActiveLocale()).toBe('en'); + }); + }); + + // ========================================================================== + // formatRelativeTime tests (locale-aware via Intl.RelativeTimeFormat) // ========================================================================== describe('formatRelativeTime', () => { const now = Date.now(); - it('should format just now for < 1 minute', () => { - expect(formatRelativeTime(now)).toBe('just now'); - expect(formatRelativeTime(now - 30000)).toBe('just now'); // 30 seconds + it('should format "now" for < 1 minute', () => { + expect(formatRelativeTime(now)).toBe('now'); + expect(formatRelativeTime(now - 30000)).toBe('now'); // 30 seconds }); it('should format minutes ago', () => { - expect(formatRelativeTime(now - 60000)).toBe('1m ago'); - expect(formatRelativeTime(now - 5 * 60000)).toBe('5m ago'); - expect(formatRelativeTime(now - 59 * 60000)).toBe('59m ago'); + expect(formatRelativeTime(now - 60000)).toBe('1 minute ago'); + expect(formatRelativeTime(now - 5 * 60000)).toBe('5 minutes ago'); + expect(formatRelativeTime(now - 59 * 60000)).toBe('59 minutes ago'); }); it('should format hours ago', () => { - expect(formatRelativeTime(now - 60 * 60000)).toBe('1h ago'); - expect(formatRelativeTime(now - 5 * 60 * 60000)).toBe('5h ago'); - expect(formatRelativeTime(now - 23 * 60 * 60000)).toBe('23h ago'); + expect(formatRelativeTime(now - 60 * 60000)).toBe('1 hour ago'); + expect(formatRelativeTime(now - 5 * 60 * 60000)).toBe('5 hours ago'); + expect(formatRelativeTime(now - 23 * 60 * 60000)).toBe('23 hours ago'); }); it('should format days ago', () => { - expect(formatRelativeTime(now - 24 * 60 * 60000)).toBe('1d ago'); - expect(formatRelativeTime(now - 5 * 24 * 60 * 60000)).toBe('5d ago'); - expect(formatRelativeTime(now - 6 * 24 * 60 * 60000)).toBe('6d ago'); + expect(formatRelativeTime(now - 24 * 60 * 60000)).toBe('yesterday'); + expect(formatRelativeTime(now - 5 * 24 * 60 * 60000)).toBe('5 days ago'); + expect(formatRelativeTime(now - 6 * 24 * 60 * 60000)).toBe('6 days ago'); }); it('should format older dates as localized date', () => { @@ -167,13 +187,24 @@ describe('shared/formatters', () => { }); it('should accept Date objects', () => { - expect(formatRelativeTime(new Date(now))).toBe('just now'); - expect(formatRelativeTime(new Date(now - 60000))).toBe('1m ago'); + expect(formatRelativeTime(new Date(now))).toBe('now'); + expect(formatRelativeTime(new Date(now - 60000))).toBe('1 minute ago'); }); it('should accept ISO date strings', () => { - expect(formatRelativeTime(new Date(now).toISOString())).toBe('just now'); - expect(formatRelativeTime(new Date(now - 60000).toISOString())).toBe('1m ago'); + expect(formatRelativeTime(new Date(now).toISOString())).toBe('now'); + expect(formatRelativeTime(new Date(now - 60000).toISOString())).toBe('1 minute ago'); + }); + + it('should respect locale parameter for Spanish', () => { + expect(formatRelativeTime(now - 5 * 60000, 'es')).toBe('hace 5 minutos'); + }); + + it('should respect locale parameter for French', () => { + const result = formatRelativeTime(now - 60 * 60000, 'fr'); + expect(result).toContain('1'); + // French: "il y a 1 heure" + expect(result.length).toBeGreaterThan(0); }); }); diff --git a/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx b/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx index ed03d82b2f..138a21152b 100644 --- a/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx +++ b/src/__tests__/web/mobile/CommandHistoryDrawer.test.tsx @@ -13,6 +13,10 @@ import React from 'react'; import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + // Mock dependencies before importing component vi.mock('../../../web/components/ThemeProvider', () => ({ useThemeColors: () => ({ @@ -124,13 +128,13 @@ describe('formatRelativeTime (via component)', () => { vi.useRealTimers(); }); - it('formats "just now" for timestamps within 60 seconds', () => { + it('formats "now" for timestamps within 60 seconds', () => { const entry = createMockEntry({ timestamp: Date.now() - 30000, // 30 seconds ago }); render(); - expect(screen.getByText('just now')).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); it('formats minutes correctly', () => { @@ -139,7 +143,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('5m ago')).toBeInTheDocument(); + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); }); it('formats hours correctly', () => { @@ -148,7 +152,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('3h ago')).toBeInTheDocument(); + expect(screen.getByText('3 hours ago')).toBeInTheDocument(); }); it('formats days correctly', () => { @@ -157,7 +161,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('2d ago')).toBeInTheDocument(); + expect(screen.getByText('2 days ago')).toBeInTheDocument(); }); it('handles boundary: exactly 1 minute', () => { @@ -166,7 +170,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('1m ago')).toBeInTheDocument(); + expect(screen.getByText('1 minute ago')).toBeInTheDocument(); }); it('handles boundary: exactly 1 hour', () => { @@ -175,7 +179,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('1h ago')).toBeInTheDocument(); + expect(screen.getByText('1 hour ago')).toBeInTheDocument(); }); it('handles boundary: exactly 1 day', () => { @@ -184,7 +188,7 @@ describe('formatRelativeTime (via component)', () => { }); render(); - expect(screen.getByText('1d ago')).toBeInTheDocument(); + expect(screen.getByText('yesterday')).toBeInTheDocument(); }); }); @@ -951,8 +955,8 @@ describe('Edge cases', () => { }); render(); - // formatRelativeTime with negative diff should show "just now" - expect(screen.getByText('just now')).toBeInTheDocument(); + // formatRelativeTime with negative diff should show "now" + expect(screen.getByText('now')).toBeInTheDocument(); vi.useRealTimers(); }); diff --git a/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx b/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx index 05c122d358..96efdfb200 100644 --- a/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx +++ b/src/__tests__/web/mobile/OfflineQueueBanner.test.tsx @@ -7,7 +7,7 @@ * and provides options to view, clear, or retry them. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { OfflineQueueBanner } from '../../../web/mobile/OfflineQueueBanner'; @@ -50,6 +50,10 @@ vi.mock('../../../web/components/ThemeProvider', () => ({ // Track haptic calls const mockTriggerHaptic = vi.fn(); +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + vi.mock('../../../web/mobile/constants', () => ({ triggerHaptic: (...args: unknown[]) => mockTriggerHaptic(...args), HAPTIC_PATTERNS: { @@ -183,86 +187,75 @@ describe('OfflineQueueBanner', () => { // ============================================================ describe('formatRelativeTime (tested via component)', () => { - // Freeze time to prevent Date.now() drift during full test suite runs - const frozenNow = 1710000000000; // fixed epoch ms - - beforeEach(() => { - vi.useFakeTimers({ now: frozenNow }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('shows "just now" for recent timestamps', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 1000 })]; // 1 second ago + it('shows "now" for recent timestamps', () => { + const queue = [createQueuedCommand({ timestamp: Date.now() - 1000 })]; // 1 second ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/just now/i)).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); - it('shows "just now" for timestamps less than 60 seconds ago', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 59000 })]; // 59 seconds ago + it('shows "now" for timestamps less than 60 seconds ago', () => { + const queue = [createQueuedCommand({ timestamp: Date.now() - 59000 })]; // 59 seconds ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/just now/i)).toBeInTheDocument(); + expect(screen.getByText('now')).toBeInTheDocument(); }); it('shows minutes ago for timestamps 1-59 minutes ago', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 120000 })]; // 2 minutes ago + const queue = [createQueuedCommand({ timestamp: Date.now() - 120000 })]; // 2 minutes ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/2m ago/)).toBeInTheDocument(); + expect(screen.getByText(/2 minutes ago/)).toBeInTheDocument(); }); - it('shows "1m ago" at exactly 60 seconds', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 60000 })]; // 1 minute ago + it('shows "1 minute ago" at exactly 60 seconds', () => { + const queue = [createQueuedCommand({ timestamp: Date.now() - 60000 })]; // 1 minute ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/1m ago/)).toBeInTheDocument(); + expect(screen.getByText(/1 minute ago/)).toBeInTheDocument(); }); it('shows hours ago for timestamps 1+ hours ago', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 3600000 })]; // 1 hour ago + const queue = [createQueuedCommand({ timestamp: Date.now() - 3600000 })]; // 1 hour ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/1h ago/)).toBeInTheDocument(); + expect(screen.getByText(/1 hour ago/)).toBeInTheDocument(); }); - it('shows "2h ago" for 2 hour old timestamp', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 7200000 })]; // 2 hours ago + it('shows "2 hours ago" for 2 hour old timestamp', () => { + const queue = [createQueuedCommand({ timestamp: Date.now() - 7200000 })]; // 2 hours ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/2h ago/)).toBeInTheDocument(); + expect(screen.getByText(/2 hours ago/)).toBeInTheDocument(); }); it('handles very old timestamps', () => { - const queue = [createQueuedCommand({ timestamp: frozenNow - 86400000 })]; // 24 hours ago + const queue = [createQueuedCommand({ timestamp: Date.now() - 86400000 })]; // 24 hours ago render(); const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - // 24 hours = 1 day, so formatRelativeTime returns "1d ago" - expect(screen.getByText(/1d ago/)).toBeInTheDocument(); + // 24 hours = 1 day, so formatRelativeTime returns "yesterday" + expect(screen.getByText(/yesterday/)).toBeInTheDocument(); }); }); @@ -658,7 +651,7 @@ describe('OfflineQueueBanner', () => { const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - expect(screen.getByText(/5m ago/)).toBeInTheDocument(); + expect(screen.getByText(/5 minutes ago/)).toBeInTheDocument(); }); it('displays attempt count when attempts > 0', () => { @@ -993,8 +986,8 @@ describe('OfflineQueueBanner', () => { const toggleButton = screen.getByRole('button', { name: /command.* queued/i }); fireEvent.click(toggleButton); - // Should show "just now" for negative time difference - expect(screen.getByText(/just now/i)).toBeInTheDocument(); + // Should show "now" for negative time difference + expect(screen.getByText('now')).toBeInTheDocument(); }); it('handles zero timestamp', () => { diff --git a/src/__tests__/web/mobile/SessionStatusBanner.test.tsx b/src/__tests__/web/mobile/SessionStatusBanner.test.tsx index 706a38d83b..3ff874ff85 100644 --- a/src/__tests__/web/mobile/SessionStatusBanner.test.tsx +++ b/src/__tests__/web/mobile/SessionStatusBanner.test.tsx @@ -60,6 +60,10 @@ vi.mock('../../../web/mobile/constants', () => ({ }, })); +vi.mock('../../../shared/i18n/config', () => ({ + default: { language: 'en' }, +})); + // Mock the logger vi.mock('../../../web/utils/logger', () => ({ webLogger: { @@ -569,7 +573,7 @@ describe('SessionStatusBanner', () => { }); describe('formatRelativeTime (via LastResponsePreviewSection)', () => { - it('shows "just now" for timestamps under 1 minute ago', () => { + it('shows "now" for timestamps under 1 minute ago', () => { const lastResponse = createLastResponse({ timestamp: Date.now() - 30000, // 30 seconds ago }); @@ -577,10 +581,10 @@ describe('SessionStatusBanner', () => { render(); - expect(screen.getByText(/just now/)).toBeInTheDocument(); + expect(screen.getByText(/now/)).toBeInTheDocument(); }); - it('shows "Xm ago" for timestamps under 1 hour ago', () => { + it('shows "X minutes ago" for timestamps under 1 hour ago', () => { const lastResponse = createLastResponse({ timestamp: Date.now() - 1800000, // 30 minutes ago }); @@ -588,10 +592,10 @@ describe('SessionStatusBanner', () => { render(); - expect(screen.getByText(/30m ago/)).toBeInTheDocument(); + expect(screen.getByText(/30 minutes ago/)).toBeInTheDocument(); }); - it('shows "Xh ago" for timestamps under 24 hours ago', () => { + it('shows "X hours ago" for timestamps under 24 hours ago', () => { const lastResponse = createLastResponse({ timestamp: Date.now() - 7200000, // 2 hours ago }); @@ -599,10 +603,10 @@ describe('SessionStatusBanner', () => { render(); - expect(screen.getByText(/2h ago/)).toBeInTheDocument(); + expect(screen.getByText(/2 hours ago/)).toBeInTheDocument(); }); - it('shows "Xd ago" for timestamps over 24 hours ago', () => { + it('shows "X days ago" for timestamps over 24 hours ago', () => { const lastResponse = createLastResponse({ timestamp: Date.now() - 172800000, // 2 days ago }); @@ -610,10 +614,10 @@ describe('SessionStatusBanner', () => { render(); - expect(screen.getByText(/2d ago/)).toBeInTheDocument(); + expect(screen.getByText(/2 days ago/)).toBeInTheDocument(); }); - it('shows "1m ago" at exactly 60 seconds', () => { + it('shows "1 minute ago" at exactly 60 seconds', () => { const lastResponse = createLastResponse({ timestamp: Date.now() - 60000, // exactly 1 minute ago }); @@ -621,7 +625,7 @@ describe('SessionStatusBanner', () => { render(); - expect(screen.getByText(/1m ago/)).toBeInTheDocument(); + expect(screen.getByText(/1 minute ago/)).toBeInTheDocument(); }); }); @@ -1287,7 +1291,7 @@ describe('SessionStatusBanner', () => { // Context usage expect(screen.getByText('3%')).toBeInTheDocument(); // Last response section - expect(screen.getByText(/5m ago/)).toBeInTheDocument(); + expect(screen.getByText(/5 minutes ago/)).toBeInTheDocument(); }); it('shows elapsed time and thinking indicator when busy with thinkingStartTime', () => { diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index 82954bcf76..0d012527a0 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -3,11 +3,12 @@ * These pure functions are used by both renderer (desktop) and web (mobile) code. * * Functions: + * - getActiveLocale: Get current locale from i18n (auto-detected) * - formatSize: File sizes (B, KB, MB, GB, TB) * - formatNumber: Large numbers with k/M/B suffixes * - formatTokens: Token counts with K/M/B suffixes (~prefix) * - formatTokensCompact: Token counts without ~prefix - * - formatRelativeTime: Relative timestamps ("5m ago", "2h ago") + * - formatRelativeTime: Locale-aware relative timestamps ("5 minutes ago", "hace 5 minutos") * - formatActiveTime: Duration display (1D, 2H 30M, <1M) * - formatElapsedTime: Precise elapsed time (1h 10m, 30s, 500ms) * - formatElapsedTimeColon: Timer-style elapsed time (mm:ss or hh:mm:ss) @@ -17,6 +18,24 @@ * - truncateCommand: Truncate command text for display with ellipsis */ +import i18n from './i18n/config'; + +/** + * Get the currently active locale from i18n, falling back to 'en'. + * Used by locale-aware formatters to auto-detect the user's language. + * + * @param override - Optional locale override; if provided, returned as-is + * @returns BCP 47 locale string (e.g., 'en', 'es', 'fr') + */ +export function getActiveLocale(override?: string): string { + if (override) return override; + try { + return i18n?.language || 'en'; + } catch { + return 'en'; + } +} + /** * Format a file size in bytes to a human-readable string. * Automatically scales to appropriate unit (B, KB, MB, GB, TB). @@ -74,13 +93,20 @@ export function formatTokensCompact(tokens: number): string { } /** - * Format a date/timestamp as relative time (e.g., "just now", "5m ago", "2h ago"). - * Accepts either a timestamp (number of milliseconds) or a date string. + * Format a date/timestamp as locale-aware relative time. + * Uses Intl.RelativeTimeFormat for localized output (e.g., "5 minutes ago" in English, + * "hace 5 minutos" in Spanish, "5 分钟前" in Chinese). + * + * Thresholds: <1m → "now", <1h → minutes, <24h → hours, <7d → days, ≥7d → formatted date. * * @param dateOrTimestamp - Either a Date object, timestamp in milliseconds, or ISO date string - * @returns Relative time string (e.g., "just now", "5m ago", "3d ago", or localized date) + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) + * @returns Locale-aware relative time string */ -export function formatRelativeTime(dateOrTimestamp: Date | number | string): string { +export function formatRelativeTime( + dateOrTimestamp: Date | number | string, + locale?: string +): string { let timestamp: number; if (typeof dateOrTimestamp === 'number') { @@ -91,18 +117,23 @@ export function formatRelativeTime(dateOrTimestamp: Date | number | string): str timestamp = dateOrTimestamp.getTime(); } + const activeLocale = getActiveLocale(locale); const now = Date.now(); const diffMs = now - timestamp; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - // Show compact date format (e.g., "Dec 3") for older dates - return new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const rtf = new Intl.RelativeTimeFormat(activeLocale, { numeric: 'auto' }); + + if (diffMins < 1) return rtf.format(0, 'second'); + if (diffMins < 60) return rtf.format(-diffMins, 'minute'); + if (diffHours < 24) return rtf.format(-diffHours, 'hour'); + if (diffDays < 7) return rtf.format(-diffDays, 'day'); + // Show locale-aware date format (e.g., "Dec 3" in English) for older dates + return new Intl.DateTimeFormat(activeLocale, { month: 'short', day: 'numeric' }).format( + new Date(timestamp) + ); } /** From de21450c76d675a68fc437ac36ca07544ba94c8d Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 02:03:08 -0400 Subject: [PATCH 08/92] MAESTRO: refactor formatElapsedTime() and formatActiveTime() to locale-aware i18n Add optional locale parameter to both functions, backed by i18n translation keys for time unit abbreviations (ms, s, m, h, d and compact M, H, D variants). Add getTimeUnitLabel() helper that resolves translations via i18n.t() with graceful fallback to English templates when i18n is uninitialized. Add "time" section to common.json with all unit abbreviation keys. Update test mock to support t() and add locale parameter test cases. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/shared/formatters.test.ts | 41 ++++++++++++++- src/shared/formatters.ts | 70 ++++++++++++++++++++----- src/shared/i18n/locales/en/common.json | 13 ++++- 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index fece78bdfa..dc972b4a2a 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -19,9 +19,31 @@ import { getActiveLocale, } from '../../shared/formatters'; -// Mock i18n to avoid initializing the full i18n stack in tests +// Mock i18n to avoid initializing the full i18n stack in tests. +// Includes a basic t() that resolves English time unit translations. +const timeTranslations: Record = { + 'common:time.milliseconds_short': '{{count}}ms', + 'common:time.seconds_short': '{{count}}s', + 'common:time.minutes_short': '{{count}}m', + 'common:time.hours_short': '{{count}}h', + 'common:time.days_short': '{{count}}d', + 'common:time.minutes_compact': '{{count}}M', + 'common:time.hours_compact': '{{count}}H', + 'common:time.days_compact': '{{count}}D', + 'common:time.less_than_minute': '<1M', +}; vi.mock('../../shared/i18n/config', () => ({ - default: { language: 'en' }, + default: { + language: 'en', + t: (key: string, opts?: Record) => { + const template = timeTranslations[key]; + if (!template) return key; + if (opts?.count !== undefined) { + return template.replace('{{count}}', String(opts.count)); + } + return template; + }, + }, })); describe('shared/formatters', () => { @@ -238,6 +260,14 @@ describe('shared/formatters', () => { expect(formatActiveTime(24 * 60 * 60000)).toBe('1D'); expect(formatActiveTime(3 * 24 * 60 * 60000)).toBe('3D'); }); + + it('should accept optional locale parameter without breaking output', () => { + // Locale parameter is accepted but English output stays the same + expect(formatActiveTime(0, 'en')).toBe('<1M'); + expect(formatActiveTime(5 * 60000, 'en')).toBe('5M'); + expect(formatActiveTime(90 * 60000, 'en')).toBe('1H 30M'); + expect(formatActiveTime(24 * 60 * 60000, 'en')).toBe('1D'); + }); }); // ========================================================================== @@ -269,6 +299,13 @@ describe('shared/formatters', () => { expect(formatElapsedTime(70 * 60000)).toBe('1h 10m'); expect(formatElapsedTime(2 * 60 * 60000 + 30 * 60000)).toBe('2h 30m'); }); + + it('should accept optional locale parameter without breaking output', () => { + expect(formatElapsedTime(500, 'en')).toBe('500ms'); + expect(formatElapsedTime(5000, 'en')).toBe('5s'); + expect(formatElapsedTime(90000, 'en')).toBe('1m 30s'); + expect(formatElapsedTime(70 * 60000, 'en')).toBe('1h 10m'); + }); }); // ========================================================================== diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index 0d012527a0..ab369827d2 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -36,6 +36,35 @@ export function getActiveLocale(override?: string): string { } } +/** + * Get a localized time unit label using i18n translations. + * Falls back to a hardcoded English template if i18n is not initialized. + * + * @param key - Translation key under 'common:time.' (e.g., 'seconds_short') + * @param count - The numeric value to interpolate + * @param fallback - English fallback template with {{count}} placeholder + * @param locale - Optional BCP 47 locale override + * @returns Formatted time unit string (e.g., "5s", "10m") + */ +function getTimeUnitLabel(key: string, count: number, fallback: string, locale?: string): string { + try { + if (typeof i18n?.t === 'function') { + const opts: Record = { count }; + if (locale) opts.lng = locale; + const fullKey = `common:time.${key}`; + // Dynamic key constructed at runtime; cast to bypass strict typed-key checking + const result = (i18n.t as (key: string, options?: Record) => string)( + fullKey, + opts + ); + if (result && result !== fullKey) return result; + } + } catch { + // i18n not initialized, use fallback + } + return fallback.replace('{{count}}', String(count)); +} + /** * Format a file size in bytes to a human-readable string. * Automatically scales to appropriate unit (B, KB, MB, GB, TB). @@ -137,51 +166,64 @@ export function formatRelativeTime( } /** - * Format duration in milliseconds as compact display string. - * Uses uppercase units (D, H, M) for consistency. + * Format duration in milliseconds as locale-aware compact display string. + * Uses uppercase units (D, H, M) for consistency in English; + * other locales use translated abbreviations via i18n. * * @param ms - Duration in milliseconds + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) * @returns Formatted string (e.g., "1D", "2H 30M", "15M", "<1M") */ -export function formatActiveTime(ms: number): string { +export function formatActiveTime(ms: number, locale?: string): string { + const activeLocale = getActiveLocale(locale); const totalSeconds = Math.floor(ms / 1000); const totalMinutes = Math.floor(totalSeconds / 60); const totalHours = Math.floor(totalMinutes / 60); const totalDays = Math.floor(totalHours / 24); if (totalDays > 0) { - return `${totalDays}D`; + return getTimeUnitLabel('days_compact', totalDays, '{{count}}D', activeLocale); } else if (totalHours > 0) { const remainingMinutes = totalMinutes % 60; if (remainingMinutes > 0) { - return `${totalHours}H ${remainingMinutes}M`; + const h = getTimeUnitLabel('hours_compact', totalHours, '{{count}}H', activeLocale); + const m = getTimeUnitLabel('minutes_compact', remainingMinutes, '{{count}}M', activeLocale); + return `${h} ${m}`; } - return `${totalHours}H`; + return getTimeUnitLabel('hours_compact', totalHours, '{{count}}H', activeLocale); } else if (totalMinutes > 0) { - return `${totalMinutes}M`; + return getTimeUnitLabel('minutes_compact', totalMinutes, '{{count}}M', activeLocale); } else { - return '<1M'; + return getTimeUnitLabel('less_than_minute', 0, '<1M', activeLocale); } } /** - * Format elapsed time in milliseconds as precise human-readable format. + * Format elapsed time in milliseconds as locale-aware precise human-readable format. * Shows milliseconds for sub-second, seconds for <1m, minutes+seconds for <1h, * and hours+minutes for longer durations. * * @param ms - Duration in milliseconds + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) * @returns Formatted string (e.g., "500ms", "30s", "5m 12s", "1h 10m") */ -export function formatElapsedTime(ms: number): string { - if (ms < 1000) return `${ms}ms`; +export function formatElapsedTime(ms: number, locale?: string): string { + const activeLocale = getActiveLocale(locale); + if (ms < 1000) return getTimeUnitLabel('milliseconds_short', ms, '{{count}}ms', activeLocale); const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return getTimeUnitLabel('seconds_short', seconds, '{{count}}s', activeLocale); const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + if (minutes < 60) { + const m = getTimeUnitLabel('minutes_short', minutes, '{{count}}m', activeLocale); + const s = getTimeUnitLabel('seconds_short', remainingSeconds, '{{count}}s', activeLocale); + return `${m} ${s}`; + } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; + const h = getTimeUnitLabel('hours_short', hours, '{{count}}h', activeLocale); + const mLabel = getTimeUnitLabel('minutes_short', remainingMinutes, '{{count}}m', activeLocale); + return `${h} ${mLabel}`; } /** diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index 94cc07b228..90343509f1 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -31,5 +31,16 @@ "export": "Export", "refresh": "Refresh", "settings": "Settings", - "help": "Help" + "help": "Help", + "time": { + "milliseconds_short": "{{count}}ms", + "seconds_short": "{{count}}s", + "minutes_short": "{{count}}m", + "hours_short": "{{count}}h", + "days_short": "{{count}}d", + "minutes_compact": "{{count}}M", + "hours_compact": "{{count}}H", + "days_compact": "{{count}}D", + "less_than_minute": "<1M" + } } From 7284b69f3785443003fe8779d630773c9da179e2 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 02:19:22 -0400 Subject: [PATCH 09/92] MAESTRO: refactor formatSize() to locale-aware Intl.NumberFormat Co-Authored-By: Claude Opus 4.6 --- src/__tests__/shared/formatters.test.ts | 14 ++++++++++ src/shared/formatters.ts | 35 +++++++++++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index dc972b4a2a..5b488454a8 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -79,6 +79,20 @@ describe('shared/formatters', () => { expect(formatSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB'); expect(formatSize(1024 * 1024 * 1024 * 1024 * 5)).toBe('5.0 TB'); }); + + it('should accept optional locale parameter without breaking output', () => { + expect(formatSize(0, 'en')).toBe('0 B'); + expect(formatSize(1536, 'en')).toBe('1.5 KB'); + expect(formatSize(1024 * 1024 * 1.5, 'en')).toBe('1.5 MB'); + }); + + it('should use locale-aware decimal separators', () => { + // French uses comma as decimal separator + expect(formatSize(1536, 'fr')).toBe('1,5 KB'); + expect(formatSize(1024 * 1024 * 1.5, 'fr')).toBe('1,5 MB'); + // German also uses comma + expect(formatSize(1024 * 1024 * 1024 * 2.5, 'de')).toBe('2,5 GB'); + }); }); // ========================================================================== diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index ab369827d2..7ba9f1dcea 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -66,18 +66,37 @@ function getTimeUnitLabel(key: string, count: number, fallback: string, locale?: } /** - * Format a file size in bytes to a human-readable string. + * Format a file size in bytes to a locale-aware human-readable string. * Automatically scales to appropriate unit (B, KB, MB, GB, TB). + * Uses Intl.NumberFormat for locale-aware number formatting (e.g., "1.5 MB" in English, "1,5 MB" in French). + * Unit suffixes are kept as international standards (B, KB, MB, GB, TB). * * @param bytes - The size in bytes - * @returns Formatted string (e.g., "1.5 MB", "256 KB") + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) + * @returns Formatted string (e.g., "1.5 MB", "1,5 MB") */ -export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`; +export function formatSize(bytes: number, locale?: string): string { + const activeLocale = getActiveLocale(locale); + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let unitIndex = 0; + let value = bytes; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + + if (unitIndex === 0) { + // Bytes are always integers, no decimal formatting needed + return `${bytes} ${units[0]}`; + } + + const formatted = new Intl.NumberFormat(activeLocale, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(value); + + return `${formatted} ${units[unitIndex]}`; } /** From 56508946345dbeaa333b32db25b9a4c6803d4ef0 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 02:28:42 -0400 Subject: [PATCH 10/92] MAESTRO: refactor formatCost() to locale-aware Intl.NumberFormat currency formatting Use Intl.NumberFormat with style:'currency' and currency:'USD' for locale-aware cost display (e.g., "$1.23" in English, "1,23 $" in German). Fixed ParticipantCard .slice(1) hack by removing DollarSign icon and using full formatted cost string. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/shared/formatters.test.ts | 24 ++++++++++++++--- src/renderer/components/ParticipantCard.tsx | 5 ++-- src/shared/formatters.ts | 29 ++++++++++++++++----- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index 5b488454a8..467d0ecf44 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -328,11 +328,13 @@ describe('shared/formatters', () => { describe('formatCost', () => { it('should format zero cost', () => { expect(formatCost(0)).toBe('$0.00'); + expect(formatCost(0, 'en')).toBe('$0.00'); }); it('should format very small costs as <$0.01', () => { expect(formatCost(0.001)).toBe('<$0.01'); expect(formatCost(0.009)).toBe('<$0.01'); + expect(formatCost(0.001, 'en')).toBe('<$0.01'); }); it('should format normal costs with 2 decimal places', () => { @@ -343,9 +345,25 @@ describe('shared/formatters', () => { }); it('should round to 2 decimal places', () => { - expect(formatCost(1.234)).toBe('$1.23'); - expect(formatCost(1.235)).toBe('$1.24'); // rounds up - expect(formatCost(1.999)).toBe('$2.00'); + expect(formatCost(1.234, 'en')).toBe('$1.23'); + expect(formatCost(1.235, 'en')).toBe('$1.24'); // rounds up + expect(formatCost(1.999, 'en')).toBe('$2.00'); + }); + + it('should use locale-aware currency formatting with explicit locale', () => { + // German uses comma for decimal, currency symbol after number + const deCost = formatCost(1.23, 'de'); + expect(deCost).toContain('1,23'); + + // French uses comma for decimal, currency symbol after number + const frCost = formatCost(1.23, 'fr'); + expect(frCost).toContain('1,23'); + }); + + it('should handle locale-aware less-than formatting', () => { + const deCost = formatCost(0.005, 'de'); + expect(deCost).toContain('<'); + expect(deCost).toContain('0,01'); }); }); diff --git a/src/renderer/components/ParticipantCard.tsx b/src/renderer/components/ParticipantCard.tsx index 2aec2b916c..d4acbfed13 100644 --- a/src/renderer/components/ParticipantCard.tsx +++ b/src/renderer/components/ParticipantCard.tsx @@ -5,7 +5,7 @@ * session ID, context usage, stats, and cost. */ -import { MessageSquare, Copy, Check, DollarSign, RotateCcw, Server } from 'lucide-react'; +import { MessageSquare, Copy, Check, RotateCcw, Server } from 'lucide-react'; import { useState, useCallback } from 'react'; import type { Theme, GroupChatParticipant, SessionState } from '../types'; import { getStatusColor } from '../utils/theme'; @@ -203,8 +203,7 @@ export function ParticipantCard({ }} title="Total cost" > - - {formatCost(participant.totalCost).slice(1)} + {formatCost(participant.totalCost)} )} {/* Reset button */} diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index 7ba9f1dcea..ba2bd73db4 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -246,16 +246,33 @@ export function formatElapsedTime(ms: number, locale?: string): string { } /** - * Format cost as USD with appropriate precision. - * Shows "<$0.01" for very small amounts. + * Format cost as locale-aware USD currency display. + * Uses Intl.NumberFormat for locale-appropriate currency formatting + * (e.g., "$1.23" in English, "1,23 $US" in French, "US$1.23" in Chinese). + * Shows a "less than minimum" indicator for very small amounts. * * @param cost - The cost in USD + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) * @returns Formatted string (e.g., "$1.23", "<$0.01", "$0.00") */ -export function formatCost(cost: number): string { - if (cost === 0) return '$0.00'; - if (cost < 0.01) return '<$0.01'; - return '$' + cost.toFixed(2); +export function formatCost(cost: number, locale?: string): string { + const activeLocale = getActiveLocale(locale); + + const formatter = new Intl.NumberFormat(activeLocale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + if (cost === 0) return formatter.format(0); + + if (cost > 0 && cost < 0.01) { + // Prepend '<' to the locale-formatted minimum amount + return '<' + formatter.format(0.01); + } + + return formatter.format(cost); } /** From 7f136a4f5572115fffd97f6fad70e1567fb2fc13 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 02:37:18 -0400 Subject: [PATCH 11/92] MAESTRO: refactor formatTokens() and formatTokensCompact() to locale-aware Intl.NumberFormat compact notation Co-Authored-By: Claude Opus 4.6 --- .../renderer/components/AboutModal.test.tsx | 3 +- .../components/ThinkingStatusPill.test.tsx | 3 +- src/__tests__/shared/formatters.test.ts | 36 +++++++++++++++++++ src/shared/formatters.ts | 34 ++++++++++++------ 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/__tests__/renderer/components/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index c8d9e0d3f1..ddf9e96565 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -1108,7 +1108,8 @@ describe('AboutModal', () => { } }); - expect(screen.getByText('1000.0M')).toBeInTheDocument(); + // Intl compact notation correctly rounds 999,999,999 to 1.0B + expect(screen.getByText('1.0B')).toBeInTheDocument(); }); it('should handle very large cost', async () => { diff --git a/src/__tests__/renderer/components/ThinkingStatusPill.test.tsx b/src/__tests__/renderer/components/ThinkingStatusPill.test.tsx index 5afb5deae9..e908abf19b 100644 --- a/src/__tests__/renderer/components/ThinkingStatusPill.test.tsx +++ b/src/__tests__/renderer/components/ThinkingStatusPill.test.tsx @@ -952,7 +952,8 @@ describe('ThinkingStatusPill', () => { it('handles large token counts', () => { const item = createThinkingItem({ currentCycleTokens: 999999 }); render(); - expect(screen.getByText('1000.0K')).toBeInTheDocument(); + // Intl compact notation correctly rounds 999,999 to 1.0M + expect(screen.getByText('1.0M')).toBeInTheDocument(); }); it('handles item with null tab (legacy session)', () => { diff --git a/src/__tests__/shared/formatters.test.ts b/src/__tests__/shared/formatters.test.ts index 467d0ecf44..e07c3a9af8 100644 --- a/src/__tests__/shared/formatters.test.ts +++ b/src/__tests__/shared/formatters.test.ts @@ -148,6 +148,25 @@ describe('shared/formatters', () => { expect(formatTokens(1000000000)).toBe('~1B'); expect(formatTokens(2500000000)).toBe('~3B'); // Rounds to nearest B }); + + it('should accept optional locale parameter without breaking output', () => { + expect(formatTokens(0, 'en')).toBe('0'); + expect(formatTokens(1000, 'en')).toBe('~1K'); + expect(formatTokens(1500, 'en')).toBe('~2K'); + expect(formatTokens(1000000, 'en')).toBe('~1M'); + }); + + it('should produce locale-aware compact notation', () => { + // German uses '.' as thousands separator in some contexts + const deResult = formatTokens(1000000, 'de'); + expect(deResult).toContain('~'); + expect(deResult.length).toBeGreaterThan(1); + + // Spanish compact notation + const esResult = formatTokens(1000, 'es'); + expect(esResult).toContain('~'); + expect(esResult.length).toBeGreaterThan(1); + }); }); // ========================================================================== @@ -170,6 +189,23 @@ describe('shared/formatters', () => { expect(formatTokensCompact(1000000)).toBe('1.0M'); expect(formatTokensCompact(2500000)).toBe('2.5M'); }); + + it('should accept optional locale parameter without breaking output', () => { + expect(formatTokensCompact(0, 'en')).toBe('0'); + expect(formatTokensCompact(1000, 'en')).toBe('1.0K'); + expect(formatTokensCompact(1500, 'en')).toBe('1.5K'); + expect(formatTokensCompact(1000000, 'en')).toBe('1.0M'); + }); + + it('should use locale-aware number formatting', () => { + // French uses comma as decimal separator + const frResult = formatTokensCompact(1500, 'fr'); + expect(frResult).toMatch(/1[,.]5/); // locale may use comma or period + + // German uses comma as decimal separator + const deResult = formatTokensCompact(2500000, 'de'); + expect(deResult).toContain('2,5'); + }); }); // ========================================================================== diff --git a/src/shared/formatters.ts b/src/shared/formatters.ts index ba2bd73db4..8cf4cb4bc0 100644 --- a/src/shared/formatters.ts +++ b/src/shared/formatters.ts @@ -113,31 +113,43 @@ export function formatNumber(num: number): string { } /** - * Format a token count with K/M/B suffix for compact display. + * Format a token count with locale-aware compact notation for display. * Uses approximate (~) prefix for larger numbers. + * Uses Intl.NumberFormat with compact notation for locale-appropriate suffixes + * (e.g., "~1K" in English, "~1 mil" in Spanish). * * @param tokens - The token count + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) * @returns Formatted string (e.g., "500", "~1K", "~2M", "~1B") */ -export function formatTokens(tokens: number): string { - if (tokens >= 1_000_000_000) return `~${Math.round(tokens / 1_000_000_000)}B`; - if (tokens >= 1_000_000) return `~${Math.round(tokens / 1_000_000)}M`; - if (tokens >= 1_000) return `~${Math.round(tokens / 1_000)}K`; - return tokens.toString(); +export function formatTokens(tokens: number, locale?: string): string { + if (tokens < 1000) return tokens.toString(); + const activeLocale = getActiveLocale(locale); + const formatted = new Intl.NumberFormat(activeLocale, { + notation: 'compact', + maximumFractionDigits: 0, + }).format(tokens); + return `~${formatted}`; } /** * Format a token count compactly without the approximate prefix. * Useful for precise token displays. + * Uses Intl.NumberFormat with compact notation for locale-aware formatting + * (e.g., "1.5K" in English, "1,5 mil" in Spanish). * * @param tokens - The token count + * @param locale - Optional BCP 47 locale override (auto-detected from i18n if omitted) * @returns Formatted string (e.g., "500", "1.5K", "2.3M", "5.8B") */ -export function formatTokensCompact(tokens: number): string { - if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`; - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; - return tokens.toString(); +export function formatTokensCompact(tokens: number, locale?: string): string { + if (tokens < 1000) return tokens.toString(); + const activeLocale = getActiveLocale(locale); + return new Intl.NumberFormat(activeLocale, { + notation: 'compact', + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(tokens); } /** From 529de4907bbc6cdca31ae34a8174ac3dafd456df Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 02:48:36 -0400 Subject: [PATCH 12/92] MAESTRO: replace hardcoded 'en-US' locale with getActiveLocale() across 21 files Renderer/web components (17 files) now use getActiveLocale() from shared/formatters for i18n-aware locale detection. Main process files (director-notes.ts, error-patterns.ts, symphony.ts) and shared templateVariables.ts use undefined to fall back to system default, avoiding browser-side i18n imports in Node.js context. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc/handlers/director-notes.ts | 4 ++-- src/main/ipc/handlers/symphony.ts | 2 +- src/main/parsers/error-patterns.ts | 4 ++-- src/renderer/components/AboutModal.tsx | 4 ++-- src/renderer/components/AgentPromptComposerModal.tsx | 6 +++--- src/renderer/components/AgentSessionsBrowser.tsx | 10 ++++++++-- src/renderer/components/GitLogViewer.tsx | 11 +++++++---- src/renderer/components/History/HistoryEntryItem.tsx | 8 ++++---- src/renderer/components/HistoryDetailModal.tsx | 6 +++--- src/renderer/components/MainPanel.tsx | 11 ++++++----- src/renderer/components/PromptComposerModal.tsx | 4 ++-- src/renderer/components/SymphonyModal.tsx | 3 ++- src/renderer/components/TransferErrorModal.tsx | 3 ++- src/renderer/components/UpdateCheckModal.tsx | 3 ++- .../components/UsageDashboard/AutoRunStats.tsx | 8 ++++++-- .../UsageDashboard/LongestAutoRunsTable.tsx | 5 +++-- src/renderer/utils/formatters.ts | 1 + src/shared/templateVariables.ts | 2 +- src/web/mobile/MobileHistoryPanel.tsx | 6 +++--- src/web/mobile/ResponseViewer.tsx | 3 ++- src/web/mobile/SessionStatusBanner.tsx | 5 +++-- 21 files changed, 65 insertions(+), 44 deletions(-) diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 8622d3d80b..2cf63c0818 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -282,12 +282,12 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen ) .join('\n'); - const cutoffDate = new Date(cutoffTime).toLocaleDateString('en-US', { + const cutoffDate = new Date(cutoffTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }); - const nowDate = new Date().toLocaleDateString('en-US', { + const nowDate = new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts index 686b0bacde..041ca90ba2 100644 --- a/src/main/ipc/handlers/symphony.ts +++ b/src/main/ipc/handlers/symphony.ts @@ -925,7 +925,7 @@ async function postPRComment( : `${seconds}s`; // Format token counts with commas - const formatNumber = (n: number) => n.toLocaleString('en-US'); + const formatNumber = (n: number) => n.toLocaleString(undefined); // Build the comment body const commentBody = `## Symphony Contribution Summary diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index 350733b40e..1b8ed39e77 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -115,8 +115,8 @@ const CLAUDE_ERROR_PATTERNS: AgentErrorPatterns = { // Captures the actual vs maximum token counts for display pattern: /prompt.*too\s+long:\s*(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i, message: (match: RegExpMatchArray) => { - const actual = parseInt(match[1], 10).toLocaleString('en-US'); - const max = parseInt(match[2], 10).toLocaleString('en-US'); + const actual = parseInt(match[1], 10).toLocaleString(undefined); + const max = parseInt(match[2], 10).toLocaleString(undefined); return `Prompt is too long: ${actual} tokens exceeds the ${max} token limit. Start a new session.`; }, recoverable: true, diff --git a/src/renderer/components/AboutModal.tsx b/src/renderer/components/AboutModal.tsx index 7c32efff5c..0f5a34b511 100644 --- a/src/renderer/components/AboutModal.tsx +++ b/src/renderer/components/AboutModal.tsx @@ -16,7 +16,7 @@ import type { GlobalAgentStats } from '../../shared/types'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import pedramAvatar from '../assets/pedram-avatar.png'; import { AchievementCard } from './AchievementCard'; -import { formatTokensCompact } from '../utils/formatters'; +import { formatTokensCompact, getActiveLocale } from '../utils/formatters'; import { Modal } from './ui/Modal'; interface AboutModalProps { @@ -308,7 +308,7 @@ export function AboutModal({ style={{ color: theme.colors.success }} > $ - {(globalStats.totalCostUsd ?? 0).toLocaleString('en-US', { + {(globalStats.totalCostUsd ?? 0).toLocaleString(getActiveLocale(), { minimumFractionDigits: 2, maximumFractionDigits: 2, })} diff --git a/src/renderer/components/AgentPromptComposerModal.tsx b/src/renderer/components/AgentPromptComposerModal.tsx index f63018ffb2..1ba8caadee 100644 --- a/src/renderer/components/AgentPromptComposerModal.tsx +++ b/src/renderer/components/AgentPromptComposerModal.tsx @@ -6,7 +6,7 @@ import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { TEMPLATE_VARIABLES } from '../utils/templateVariables'; import { useTemplateAutocomplete } from '../hooks'; import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown'; -import { estimateTokenCount } from '../../shared/formatters'; +import { estimateTokenCount, getActiveLocale } from '../../shared/formatters'; interface AgentPromptComposerModalProps { isOpen: boolean; @@ -255,8 +255,8 @@ export function AgentPromptComposerModal({ style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }} >
- {value.length.toLocaleString('en-US')} characters - ~{tokenCount.toLocaleString('en-US')} tokens + {value.length.toLocaleString(getActiveLocale())} characters + ~{tokenCount.toLocaleString(getActiveLocale())} tokens
;', '}'].join( + '\n' + ) + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'Delete item' && f.attribute === 'title')).toBe(true); + }); + + it('detects hardcoded placeholder attributes', () => { + const file = writeTmpTsx( + 'Placeholder.tsx', + [ + 'export function Comp() {', + ' return ;', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect( + findings.some((f) => f.text === 'Enter your name...' && f.attribute === 'placeholder') + ).toBe(true); + }); + + it('detects hardcoded aria-label attributes', () => { + const file = writeTmpTsx( + 'AriaLabel.tsx', + [ + 'export function Comp() {', + ' return ;', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'Close modal' && f.attribute === 'aria-label')).toBe( + true + ); + }); + + it('detects toast message prop values', () => { + const file = writeTmpTsx( + 'Toast.tsx', + [ + 'export function notify() {', + " notifyToast({ title: 'Connection lost', message: 'Please reconnect', type: 'error' });", + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'Connection lost' && f.type === 'prop-value')).toBe( + true + ); + expect(findings.some((f) => f.text === 'Please reconnect' && f.type === 'prop-value')).toBe( + true + ); + }); + + it('skips strings wrapped in t()', () => { + const file = writeTmpTsx( + 'Translated.tsx', + [ + 'export function Comp() {', + ' const { t } = useTranslation();', + ' return {t("common:save")};', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'common:save')).toBe(false); + }); + + it('skips className values', () => { + const file = writeTmpTsx( + 'ClassName.tsx', + [ + 'export function Comp() {', + ' return
Text
;', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'flex items-center gap-2')).toBe(false); + }); + + it('skips comment-only lines', () => { + const file = writeTmpTsx( + 'Comment.tsx', + [ + 'export function Comp() {', + ' // title="Not a real attribute"', + ' /* placeholder="Also not real" */', + ' return
;', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.length).toBe(0); + }); + + it('skips console.log strings', () => { + const file = writeTmpTsx( + 'Console.tsx', + [ + 'export function Comp() {', + " console.log('Loading component');", + ' return
;', + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect(findings.some((f) => f.text === 'Loading component')).toBe(false); + }); + + it('handles curly-brace attribute syntax', () => { + const file = writeTmpTsx( + 'CurlyAttr.tsx', + [ + 'export function Comp() {', + " return ;", + '}', + ].join('\n') + ); + + const findings = scanFile(file); + expect( + findings.some((f) => f.text === 'Type something here...' && f.attribute === 'placeholder') + ).toBe(true); + }); +}); + +// ── collectTsxFiles ──────────────────────────────────────────────────────── + +describe('collectTsxFiles', () => { + it('finds .tsx files recursively', () => { + writeTmpTsx('collect/A.tsx', '
'); + writeTmpTsx('collect/sub/B.tsx', '
'); + writeTmpTsx('collect/sub/deep/C.tsx', '
'); + writeTmpTsx('collect/not-tsx.ts', 'export const x = 1;'); + + const files = collectTsxFiles(path.join(tmpDir, 'collect')); + const names = files.map((f) => path.basename(f)); + + expect(names).toContain('A.tsx'); + expect(names).toContain('B.tsx'); + expect(names).toContain('C.tsx'); + expect(names).not.toContain('not-tsx.ts'); + }); + + it('returns empty array for non-existent directory', () => { + const files = collectTsxFiles('/tmp/non-existent-dir-12345'); + expect(files).toEqual([]); + }); +}); From 3c0943644410f8a1b75d28de7ec1e165d10118ad Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 09:31:46 -0400 Subject: [PATCH 17/92] MAESTRO: add i18n constantKeys.ts and shortcuts namespace for non-React contexts Adds typed SHORTCUT_LABELS key constants mapping all 74 shortcut IDs to translation keys, enabling constants files to reference i18n keys without React hooks. Registers 'shortcuts' namespace in i18n config/types and creates en/shortcuts.json with all shortcut labels. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/shared/constantKeys.test.ts | 64 ++++++++++++++ src/shared/i18n/config.ts | 5 +- src/shared/i18n/constantKeys.ts | 100 ++++++++++++++++++++++ src/shared/i18n/locales/ar/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/bn/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/de/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/en/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/es/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/fr/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/hi/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/pt/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/locales/zh/shortcuts.json | 76 ++++++++++++++++ src/shared/i18n/types.ts | 2 + 13 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/shared/constantKeys.test.ts create mode 100644 src/shared/i18n/constantKeys.ts create mode 100644 src/shared/i18n/locales/ar/shortcuts.json create mode 100644 src/shared/i18n/locales/bn/shortcuts.json create mode 100644 src/shared/i18n/locales/de/shortcuts.json create mode 100644 src/shared/i18n/locales/en/shortcuts.json create mode 100644 src/shared/i18n/locales/es/shortcuts.json create mode 100644 src/shared/i18n/locales/fr/shortcuts.json create mode 100644 src/shared/i18n/locales/hi/shortcuts.json create mode 100644 src/shared/i18n/locales/pt/shortcuts.json create mode 100644 src/shared/i18n/locales/zh/shortcuts.json diff --git a/src/__tests__/shared/constantKeys.test.ts b/src/__tests__/shared/constantKeys.test.ts new file mode 100644 index 0000000000..7e90a31616 --- /dev/null +++ b/src/__tests__/shared/constantKeys.test.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Tests for i18n constantKeys — typed key constants for non-React contexts. + */ + +import { describe, it, expect } from 'vitest'; +import { SHORTCUT_LABELS } from '../../shared/i18n/constantKeys'; +import type { ShortcutLabelKey } from '../../shared/i18n/constantKeys'; +import { + DEFAULT_SHORTCUTS, + FIXED_SHORTCUTS, + TAB_SHORTCUTS, +} from '../../renderer/constants/shortcuts'; +import shortcutsEn from '../../shared/i18n/locales/en/shortcuts.json'; + +describe('SHORTCUT_LABELS', () => { + it('has a key for every DEFAULT_SHORTCUTS entry', () => { + for (const id of Object.keys(DEFAULT_SHORTCUTS)) { + expect(SHORTCUT_LABELS).toHaveProperty(id); + } + }); + + it('has a key for every FIXED_SHORTCUTS entry', () => { + for (const id of Object.keys(FIXED_SHORTCUTS)) { + expect(SHORTCUT_LABELS).toHaveProperty(id); + } + }); + + it('has a key for every TAB_SHORTCUTS entry', () => { + for (const id of Object.keys(TAB_SHORTCUTS)) { + expect(SHORTCUT_LABELS).toHaveProperty(id); + } + }); + + it('all values use the shortcuts: namespace prefix', () => { + for (const [, value] of Object.entries(SHORTCUT_LABELS)) { + expect(value).toMatch(/^shortcuts:/); + } + }); + + it('all translation keys reference valid keys in shortcuts.json', () => { + for (const [, value] of Object.entries(SHORTCUT_LABELS)) { + const jsonKey = value.replace('shortcuts:', ''); + expect(shortcutsEn).toHaveProperty(jsonKey); + } + }); + + it('shortcuts.json values match the current shortcut labels', () => { + const allShortcuts = { ...DEFAULT_SHORTCUTS, ...FIXED_SHORTCUTS, ...TAB_SHORTCUTS }; + for (const [id, translationKey] of Object.entries(SHORTCUT_LABELS)) { + const jsonKey = translationKey.replace('shortcuts:', ''); + const jsonValue = (shortcutsEn as Record)[jsonKey]; + const shortcut = allShortcuts[id]; + if (shortcut) { + expect(jsonValue).toBe(shortcut.label); + } + } + }); + + it('exports ShortcutLabelKey type that matches values', () => { + // Type-level test: ensure a known value satisfies the type + const key: ShortcutLabelKey = SHORTCUT_LABELS.toggleSidebar; + expect(key).toBe('shortcuts:toggle_sidebar'); + }); +}); diff --git a/src/shared/i18n/config.ts b/src/shared/i18n/config.ts index c6f4c439cc..d7ea76ac0d 100644 --- a/src/shared/i18n/config.ts +++ b/src/shared/i18n/config.ts @@ -6,7 +6,7 @@ * bundled JSON resources for translation strings. * * Supported languages: en, es, fr, de, zh, hi, ar, bn, pt - * Namespaces: common, settings, modals, menus, notifications, accessibility + * Namespaces: common, settings, modals, menus, notifications, accessibility, shortcuts */ import i18n from 'i18next'; @@ -20,6 +20,7 @@ import modalsEn from './locales/en/modals.json'; import menusEn from './locales/en/menus.json'; import notificationsEn from './locales/en/notifications.json'; import accessibilityEn from './locales/en/accessibility.json'; +import shortcutsEn from './locales/en/shortcuts.json'; export const SUPPORTED_LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; @@ -31,6 +32,7 @@ export const I18N_NAMESPACES = [ 'menus', 'notifications', 'accessibility', + 'shortcuts', ] as const; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; @@ -61,6 +63,7 @@ export function initI18n(): Promise { menus: menusEn, notifications: notificationsEn, accessibility: accessibilityEn, + shortcuts: shortcutsEn, }, }, diff --git a/src/shared/i18n/constantKeys.ts b/src/shared/i18n/constantKeys.ts new file mode 100644 index 0000000000..bfc808403f --- /dev/null +++ b/src/shared/i18n/constantKeys.ts @@ -0,0 +1,100 @@ +/** + * Typed i18n Key Constants for Non-React Contexts + * + * Constants files (shortcuts.ts, conductorBadges.ts, etc.) cannot use + * React hooks like useTranslation(). This module exports typed key + * constants that can be resolved at render time via i18n.t(). + * + * Usage: + * import { SHORTCUT_LABELS } from '@shared/i18n/constantKeys'; + * const label = i18n.t(SHORTCUT_LABELS.toggleSidebar); + */ + +// -- Shortcut Labels -- +// Maps shortcut IDs (camelCase, matching shortcuts.ts keys) to +// their translation keys in the 'shortcuts' namespace. + +export const SHORTCUT_LABELS = { + // DEFAULT_SHORTCUTS + toggleSidebar: 'shortcuts:toggle_sidebar' as const, + toggleRightPanel: 'shortcuts:toggle_right_panel' as const, + cyclePrev: 'shortcuts:cycle_prev' as const, + cycleNext: 'shortcuts:cycle_next' as const, + navBack: 'shortcuts:nav_back' as const, + navForward: 'shortcuts:nav_forward' as const, + newInstance: 'shortcuts:new_instance' as const, + newGroupChat: 'shortcuts:new_group_chat' as const, + killInstance: 'shortcuts:kill_instance' as const, + moveToGroup: 'shortcuts:move_to_group' as const, + toggleMode: 'shortcuts:toggle_mode' as const, + quickAction: 'shortcuts:quick_action' as const, + help: 'shortcuts:help' as const, + settings: 'shortcuts:settings' as const, + agentSettings: 'shortcuts:agent_settings' as const, + goToFiles: 'shortcuts:go_to_files' as const, + goToHistory: 'shortcuts:go_to_history' as const, + goToAutoRun: 'shortcuts:go_to_auto_run' as const, + copyFilePath: 'shortcuts:copy_file_path' as const, + toggleMarkdownMode: 'shortcuts:toggle_markdown_mode' as const, + toggleAutoRunExpanded: 'shortcuts:toggle_auto_run_expanded' as const, + focusInput: 'shortcuts:focus_input' as const, + focusSidebar: 'shortcuts:focus_sidebar' as const, + viewGitDiff: 'shortcuts:view_git_diff' as const, + viewGitLog: 'shortcuts:view_git_log' as const, + agentSessions: 'shortcuts:agent_sessions' as const, + systemLogs: 'shortcuts:system_logs' as const, + processMonitor: 'shortcuts:process_monitor' as const, + usageDashboard: 'shortcuts:usage_dashboard' as const, + jumpToBottom: 'shortcuts:jump_to_bottom' as const, + prevTab: 'shortcuts:prev_tab' as const, + nextTab: 'shortcuts:next_tab' as const, + openImageCarousel: 'shortcuts:open_image_carousel' as const, + toggleTabStar: 'shortcuts:toggle_tab_star' as const, + openPromptComposer: 'shortcuts:open_prompt_composer' as const, + openWizard: 'shortcuts:open_wizard' as const, + fuzzyFileSearch: 'shortcuts:fuzzy_file_search' as const, + toggleBookmark: 'shortcuts:toggle_bookmark' as const, + openSymphony: 'shortcuts:open_symphony' as const, + toggleAutoScroll: 'shortcuts:toggle_auto_scroll' as const, + directorNotes: 'shortcuts:director_notes' as const, + + // FIXED_SHORTCUTS + jumpToSession: 'shortcuts:jump_to_session' as const, + filterFiles: 'shortcuts:filter_files' as const, + filterSessions: 'shortcuts:filter_sessions' as const, + filterHistory: 'shortcuts:filter_history' as const, + searchLogs: 'shortcuts:search_logs' as const, + searchOutput: 'shortcuts:search_output' as const, + searchDirectorNotes: 'shortcuts:search_director_notes' as const, + filePreviewBack: 'shortcuts:file_preview_back' as const, + filePreviewForward: 'shortcuts:file_preview_forward' as const, + + // TAB_SHORTCUTS + tabSwitcher: 'shortcuts:tab_switcher' as const, + newTab: 'shortcuts:new_tab' as const, + closeTab: 'shortcuts:close_tab' as const, + closeAllTabs: 'shortcuts:close_all_tabs' as const, + closeOtherTabs: 'shortcuts:close_other_tabs' as const, + closeTabsLeft: 'shortcuts:close_tabs_left' as const, + closeTabsRight: 'shortcuts:close_tabs_right' as const, + reopenClosedTab: 'shortcuts:reopen_closed_tab' as const, + renameTab: 'shortcuts:rename_tab' as const, + toggleReadOnlyMode: 'shortcuts:toggle_read_only_mode' as const, + toggleSaveToHistory: 'shortcuts:toggle_save_to_history' as const, + toggleShowThinking: 'shortcuts:toggle_show_thinking' as const, + filterUnreadTabs: 'shortcuts:filter_unread_tabs' as const, + toggleTabUnread: 'shortcuts:toggle_tab_unread' as const, + goToTab1: 'shortcuts:go_to_tab_1' as const, + goToTab2: 'shortcuts:go_to_tab_2' as const, + goToTab3: 'shortcuts:go_to_tab_3' as const, + goToTab4: 'shortcuts:go_to_tab_4' as const, + goToTab5: 'shortcuts:go_to_tab_5' as const, + goToTab6: 'shortcuts:go_to_tab_6' as const, + goToTab7: 'shortcuts:go_to_tab_7' as const, + goToTab8: 'shortcuts:go_to_tab_8' as const, + goToTab9: 'shortcuts:go_to_tab_9' as const, + goToLastTab: 'shortcuts:go_to_last_tab' as const, +} as const; + +/** Union of all shortcut translation keys */ +export type ShortcutLabelKey = (typeof SHORTCUT_LABELS)[keyof typeof SHORTCUT_LABELS]; diff --git a/src/shared/i18n/locales/ar/shortcuts.json b/src/shared/i18n/locales/ar/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/ar/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/bn/shortcuts.json b/src/shared/i18n/locales/bn/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/bn/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/de/shortcuts.json b/src/shared/i18n/locales/de/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/de/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/en/shortcuts.json b/src/shared/i18n/locales/en/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/en/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/es/shortcuts.json b/src/shared/i18n/locales/es/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/es/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/fr/shortcuts.json b/src/shared/i18n/locales/fr/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/fr/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/hi/shortcuts.json b/src/shared/i18n/locales/hi/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/hi/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/pt/shortcuts.json b/src/shared/i18n/locales/pt/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/pt/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/locales/zh/shortcuts.json b/src/shared/i18n/locales/zh/shortcuts.json new file mode 100644 index 0000000000..0c0fd340e7 --- /dev/null +++ b/src/shared/i18n/locales/zh/shortcuts.json @@ -0,0 +1,76 @@ +{ + "toggle_sidebar": "Toggle Left Panel", + "toggle_right_panel": "Toggle Right Panel", + "cycle_prev": "Previous Agent", + "cycle_next": "Next Agent", + "nav_back": "Navigate Back", + "nav_forward": "Navigate Forward", + "new_instance": "New Agent", + "new_group_chat": "New Group Chat", + "kill_instance": "Remove", + "move_to_group": "Move Session to Group", + "toggle_mode": "Switch AI/Shell Mode", + "quick_action": "Quick Actions", + "help": "Show Shortcuts", + "settings": "Open Settings", + "agent_settings": "Open Agent Settings", + "go_to_files": "Go to Files Tab", + "go_to_history": "Go to History Tab", + "go_to_auto_run": "Go to Auto Run Tab", + "copy_file_path": "Copy File Path (in Preview)", + "toggle_markdown_mode": "Toggle Edit/Preview", + "toggle_auto_run_expanded": "Toggle Auto Run Expanded", + "focus_input": "Toggle Input/Output Focus", + "focus_sidebar": "Focus Left Panel", + "view_git_diff": "View Git Diff", + "view_git_log": "View Git Log", + "agent_sessions": "View Agent Sessions", + "system_logs": "System Log Viewer", + "process_monitor": "System Process Monitor", + "usage_dashboard": "Usage Dashboard", + "jump_to_bottom": "Jump to Bottom", + "prev_tab": "Previous Tab", + "next_tab": "Next Tab", + "open_image_carousel": "Open Image Carousel", + "toggle_tab_star": "Toggle Tab Star", + "open_prompt_composer": "Open Prompt Composer", + "open_wizard": "New Agent Wizard", + "fuzzy_file_search": "Fuzzy File Search", + "toggle_bookmark": "Toggle Bookmark", + "open_symphony": "Maestro Symphony", + "toggle_auto_scroll": "Toggle Auto-Scroll AI Output", + "director_notes": "Director's Notes", + "jump_to_session": "Jump to Session (1-9, 0=10th)", + "filter_files": "Filter Files (in Files tab)", + "filter_sessions": "Filter Sessions (in Left Panel)", + "filter_history": "Filter History (in History tab)", + "search_logs": "Search System Logs", + "search_output": "Search Output (in Main Window)", + "search_director_notes": "Search Director's Notes", + "file_preview_back": "File Preview: Go Back", + "file_preview_forward": "File Preview: Go Forward", + "tab_switcher": "Tab Switcher", + "new_tab": "New Tab", + "close_tab": "Close Tab", + "close_all_tabs": "Close All Tabs", + "close_other_tabs": "Close Other Tabs", + "close_tabs_left": "Close Tabs to Left", + "close_tabs_right": "Close Tabs to Right", + "reopen_closed_tab": "Reopen Closed Tab", + "rename_tab": "Rename Tab", + "toggle_read_only_mode": "Toggle Read-Only Mode", + "toggle_save_to_history": "Toggle Save to History", + "toggle_show_thinking": "Toggle Show Thinking", + "filter_unread_tabs": "Filter Unread Tabs", + "toggle_tab_unread": "Toggle Tab Unread", + "go_to_tab_1": "Go to Tab 1", + "go_to_tab_2": "Go to Tab 2", + "go_to_tab_3": "Go to Tab 3", + "go_to_tab_4": "Go to Tab 4", + "go_to_tab_5": "Go to Tab 5", + "go_to_tab_6": "Go to Tab 6", + "go_to_tab_7": "Go to Tab 7", + "go_to_tab_8": "Go to Tab 8", + "go_to_tab_9": "Go to Tab 9", + "go_to_last_tab": "Go to Last Tab" +} diff --git a/src/shared/i18n/types.ts b/src/shared/i18n/types.ts index 75986096ea..777e2ffba4 100644 --- a/src/shared/i18n/types.ts +++ b/src/shared/i18n/types.ts @@ -13,6 +13,7 @@ import type modalsEn from './locales/en/modals.json'; import type menusEn from './locales/en/menus.json'; import type notificationsEn from './locales/en/notifications.json'; import type accessibilityEn from './locales/en/accessibility.json'; +import type shortcutsEn from './locales/en/shortcuts.json'; /** Map each namespace to its translation key set (derived from English base files) */ export interface I18nResources { @@ -22,6 +23,7 @@ export interface I18nResources { menus: typeof menusEn; notifications: typeof notificationsEn; accessibility: typeof accessibilityEn; + shortcuts: typeof shortcutsEn; } /** Extract valid translation keys for a given namespace */ From e9d2363105d02521d80181be11eb82ece15112d5 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 11:09:49 -0400 Subject: [PATCH 18/92] MAESTRO: add tailwindcss-rtl plugin for RTL layout support Installs tailwindcss-rtl as a dev dependency and configures it in tailwind.config.mjs. This provides directional utility classes (ms-*, me-*, ps-*, pe-*, start-*, end-*, text-start, text-end) that automatically flip in RTL mode, replacing fixed LTR classes like ml-*, mr-*, pl-*, pr-*. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 8 ++++++++ package.json | 1 + tailwind.config.mjs | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e6be21b10b..65ca776516 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,6 +107,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.4.1", + "tailwindcss-rtl": "^0.9.0", "typescript": "^5.3.3", "typescript-eslint": "^8.50.1", "vite": "^5.0.11", @@ -17599,6 +17600,13 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-rtl": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz", + "integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", diff --git a/package.json b/package.json index 51922e0e7c..4c234e6fcb 100644 --- a/package.json +++ b/package.json @@ -310,6 +310,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.4.1", + "tailwindcss-rtl": "^0.9.0", "typescript": "^5.3.3", "typescript-eslint": "^8.50.1", "vite": "^5.0.11", diff --git a/tailwind.config.mjs b/tailwind.config.mjs index c1849ad561..83e5f9eb65 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -1,3 +1,5 @@ +import tailwindcssRtl from 'tailwindcss-rtl'; + /** @type {import('tailwindcss').Config} */ export default { content: ['./src/renderer/**/*.{js,ts,jsx,tsx}', './src/web/**/*.{js,ts,jsx,tsx}'], @@ -8,5 +10,5 @@ export default { }, }, }, - plugins: [], + plugins: [tailwindcssRtl], }; From 156fced38c8945855e160199429b2534dd6765e8 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 11:24:57 -0400 Subject: [PATCH 19/92] MAESTRO: add DirectionProvider for RTL-aware layout direction management Centralizes direction (LTR/RTL) logic into a single component that sets dir, data-dir, lang attributes and --dir-start/--dir-end CSS custom properties on document.documentElement based on the active language. Co-Authored-By: Claude Opus 4.6 --- .../shared/DirectionProvider.test.tsx | 120 ++++++++++++++++++ src/renderer/App.tsx | 14 +- .../components/shared/DirectionProvider.tsx | 55 ++++++++ src/renderer/hooks/settings/useSettings.ts | 13 +- src/renderer/stores/settingsStore.ts | 11 +- 5 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/renderer/components/shared/DirectionProvider.test.tsx create mode 100644 src/renderer/components/shared/DirectionProvider.tsx diff --git a/src/__tests__/renderer/components/shared/DirectionProvider.test.tsx b/src/__tests__/renderer/components/shared/DirectionProvider.test.tsx new file mode 100644 index 0000000000..50afc2af3e --- /dev/null +++ b/src/__tests__/renderer/components/shared/DirectionProvider.test.tsx @@ -0,0 +1,120 @@ +/** + * @fileoverview Tests for DirectionProvider component + * Tests: dir/data-dir attribute setting, CSS custom properties, RTL detection + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { + DirectionProvider, + isRtlLanguage, +} from '../../../../renderer/components/shared/DirectionProvider'; + +// Mock settingsStore +const mockState = { language: 'en', settingsLoaded: true }; +vi.mock('../../../../renderer/stores/settingsStore', () => ({ + useSettingsStore: (selector: (s: typeof mockState) => unknown) => selector(mockState), +})); + +// Mock i18n config +vi.mock('../../../../shared/i18n/config', () => ({ + RTL_LANGUAGES: ['ar'] as string[], +})); + +describe('isRtlLanguage', () => { + it('returns true for Arabic', () => { + expect(isRtlLanguage('ar')).toBe(true); + }); + + it('returns false for English', () => { + expect(isRtlLanguage('en')).toBe(false); + }); + + it('returns false for unknown language codes', () => { + expect(isRtlLanguage('xx')).toBe(false); + }); +}); + +describe('DirectionProvider', () => { + beforeEach(() => { + // Reset document root attributes + const root = document.documentElement; + root.removeAttribute('dir'); + root.removeAttribute('data-dir'); + root.removeAttribute('lang'); + root.style.removeProperty('--dir-start'); + root.style.removeProperty('--dir-end'); + // Reset mock state + mockState.language = 'en'; + mockState.settingsLoaded = true; + }); + + it('renders children', () => { + const { getByText } = render( + + Hello + + ); + expect(getByText('Hello')).toBeTruthy(); + }); + + it('sets LTR attributes for English', () => { + render( + +
+ + ); + const root = document.documentElement; + expect(root.dir).toBe('ltr'); + expect(root.getAttribute('data-dir')).toBe('ltr'); + expect(root.lang).toBe('en'); + }); + + it('sets CSS custom properties for LTR', () => { + render( + +
+ + ); + const root = document.documentElement; + expect(root.style.getPropertyValue('--dir-start')).toBe('left'); + expect(root.style.getPropertyValue('--dir-end')).toBe('right'); + }); + + it('sets RTL attributes for Arabic', () => { + mockState.language = 'ar'; + render( + +
+ + ); + const root = document.documentElement; + expect(root.dir).toBe('rtl'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.lang).toBe('ar'); + }); + + it('sets CSS custom properties for RTL', () => { + mockState.language = 'ar'; + render( + +
+ + ); + const root = document.documentElement; + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('does not set attributes when settings not yet loaded', () => { + mockState.settingsLoaded = false; + render( + +
+ + ); + const root = document.documentElement; + // Should not have been set since settingsLoaded is false + expect(root.dir).toBe(''); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c0..5323be2012 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,6 +21,7 @@ import { TourOverlay } from './components/Wizard/tour'; // CONDUCTOR_BADGES moved to useAutoRunAchievements hook import { EmptyStateView } from './components/EmptyStateView'; import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal'; +import { DirectionProvider } from './components/shared/DirectionProvider'; // Lazy-loaded components for performance (rarely-used heavy modals) // These are loaded on-demand when the user first opens them @@ -3231,15 +3232,18 @@ function MaestroConsoleInner() { * MaestroConsole - Main application component with context providers * * Wraps MaestroConsoleInner with context providers for centralized state management. + * DirectionProvider - RTL/LTR direction based on active language * InputProvider - centralized input state management * InlineWizardProvider - inline /wizard command state management */ export default function MaestroConsole() { return ( - - - - - + + + + + + + ); } diff --git a/src/renderer/components/shared/DirectionProvider.tsx b/src/renderer/components/shared/DirectionProvider.tsx new file mode 100644 index 0000000000..1f4287343c --- /dev/null +++ b/src/renderer/components/shared/DirectionProvider.tsx @@ -0,0 +1,55 @@ +/** + * DirectionProvider — RTL-aware layout wrapper. + * + * Reads the current language from the settings store, computes the text + * direction (LTR/RTL), and applies the following to document.documentElement: + * - dir="rtl" | "ltr" + * - data-dir="rtl" | "ltr" (for CSS attribute selectors) + * - lang="{language code}" + * - CSS custom properties --dir-start / --dir-end + * + * Wrap the app root with this component so every child inherits the + * correct direction automatically. When the language changes the + * attributes update synchronously in a useEffect. + */ + +import React, { useEffect } from 'react'; +import { useSettingsStore } from '../../stores/settingsStore'; +import { RTL_LANGUAGES, type SupportedLanguage } from '../../../shared/i18n/config'; + +/** Returns true when the given language code is a right-to-left language. */ +export function isRtlLanguage(lang: string): boolean { + return RTL_LANGUAGES.includes(lang as SupportedLanguage); +} + +export interface DirectionProviderProps { + children: React.ReactNode; +} + +/** + * Applies direction-related attributes and CSS custom properties to the + * document root element whenever the active language changes. + */ +export function DirectionProvider({ children }: DirectionProviderProps): React.ReactElement { + const language = useSettingsStore((s) => s.language); + const settingsLoaded = useSettingsStore((s) => s.settingsLoaded); + + useEffect(() => { + if (!settingsLoaded) return; + + const rtl = isRtlLanguage(language); + const dir = rtl ? 'rtl' : 'ltr'; + const root = document.documentElement; + + // Core direction attributes + root.lang = language; + root.dir = dir; + root.setAttribute('data-dir', dir); + + // CSS custom properties for logical positioning fallbacks + root.style.setProperty('--dir-start', rtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', rtl ? 'left' : 'right'); + }, [language, settingsLoaded]); + + return <>{children}; +} diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index c877718005..e0ca0e85c0 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -33,8 +33,6 @@ import { selectIsLeaderboardRegistered, } from '../../stores/settingsStore'; import type { DocumentGraphLayoutType } from '../../stores/settingsStore'; -import { RTL_LANGUAGES } from '../../../shared/i18n/config'; -import type { SupportedLanguage } from '../../../shared/i18n/config'; export interface UseSettingsReturn { // Loading state @@ -345,15 +343,8 @@ export function useSettings(): UseSettingsReturn { } }, [store.fontSize, store.settingsLoaded]); - // Apply language attributes to HTML root element for i18n and RTL support - useEffect(() => { - if (store.settingsLoaded) { - document.documentElement.lang = store.language; - document.documentElement.dir = RTL_LANGUAGES.includes(store.language as SupportedLanguage) - ? 'rtl' - : 'ltr'; - } - }, [store.language, store.settingsLoaded]); + // Language direction attributes (dir, data-dir, lang, CSS custom properties) + // are now managed by DirectionProvider — see components/shared/DirectionProvider.tsx return { ...store, diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index ebe23f7f33..e6db8d03de 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -705,10 +705,15 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('language', value); localStorage.setItem(LANGUAGE_STORAGE_KEY, value); i18n.changeLanguage(value); + // Direction attributes are applied reactively by DirectionProvider. + // We also set them here for immediate feedback before the next React render. + const rtl = RTL_LANGUAGES.includes(value as SupportedLanguage); + const dir = rtl ? 'rtl' : 'ltr'; document.documentElement.lang = value; - document.documentElement.dir = RTL_LANGUAGES.includes(value as SupportedLanguage) - ? 'rtl' - : 'ltr'; + document.documentElement.dir = dir; + document.documentElement.setAttribute('data-dir', dir); + document.documentElement.style.setProperty('--dir-start', rtl ? 'right' : 'left'); + document.documentElement.style.setProperty('--dir-end', rtl ? 'left' : 'right'); }, setDocumentGraphShowExternalLinks: (value) => { From da39d371055e228818abd1b53bacfddc14fda8e5 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 11:33:16 -0400 Subject: [PATCH 20/92] MAESTRO: convert SessionList, MainPanel, RightPanel from fixed LTR to logical CSS properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert directional Tailwind classes to RTL-aware logical properties: - ml-*/mr-* → ms-*/me-* (margin-inline-start/end) - border-l/border-r → border-s/border-e (border-inline-start/end) - left-*/right-* → start-*/end-* (inset-inline-start/end) - text-left → text-start - rounded-bl → rounded-es (end-start corner) - borderLeft (inline) → borderInlineStart Updated test selectors to match new class names. Co-Authored-By: Claude Opus 4.6 --- .../renderer/components/SessionList.test.tsx | 4 +-- src/renderer/components/MainPanel.tsx | 16 ++++----- src/renderer/components/RightPanel.tsx | 4 +-- .../components/SessionList/SessionList.tsx | 34 ++++++++++--------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index c16f09692a..b71b782fe6 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -1977,7 +1977,7 @@ describe('SessionList', () => { const { container } = render(); // Click on palette container - const palette = container.querySelector('.ml-8.mr-3.mt-1.mb-2.flex'); + const palette = container.querySelector('.ms-8.me-3.mt-1.mb-2.flex'); fireEvent.click(palette!); expect(toggleGroup).toHaveBeenCalledWith('g1'); @@ -2716,7 +2716,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('Ungrouped Agents')); // Find and click the palette container (not the indicator) - const paletteContainer = container.querySelector('.ml-8.mr-3.mt-1.mb-2.flex'); + const paletteContainer = container.querySelector('.ms-8.me-3.mt-1.mb-2.flex'); if (paletteContainer) { fireEvent.click(paletteContainer); diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 272129acdf..ef0d8c8ced 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -934,7 +934,7 @@ export const MainPanel = React.memo( {...gitTooltip.contentHandlers} />
{gitInfo.branch} -
+
{gitInfo.ahead > 0 && ( @@ -1015,7 +1015,7 @@ export const MainPanel = React.memo( const url = remoteUrlToBrowserUrl(gitInfo.remote); if (url) window.maestro.shell.openExternal(url); }} - className="text-xs font-mono truncate hover:underline text-left" + className="text-xs font-mono truncate hover:underline text-start" style={{ color: theme.colors.textMain }} title={`Open ${gitInfo.remote}`} > @@ -1028,7 +1028,7 @@ export const MainPanel = React.memo( e.stopPropagation(); copyToClipboard(gitInfo.remote); }} - className="p-1 rounded hover:bg-white/10 transition-colors ml-auto shrink-0" + className="p-1 rounded hover:bg-white/10 transition-colors ms-auto shrink-0" title="Copy remote URL" > - + )} @@ -1218,7 +1218,7 @@ export const MainPanel = React.memo( hasCapability('supportsUsageStats') && activeTabContextWindow > 0 && (
{/* Full label shown at wide widths, compact label shown at narrow widths via CSS */} @@ -1259,7 +1259,7 @@ export const MainPanel = React.memo( {...contextTooltip.contentHandlers} />
Reasoning Tokens - + (in output) diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 5fad73c7a0..6840a99020 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -381,7 +381,7 @@ export const RightPanel = memo(
)} diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index b8a276374c..4e9611aa93 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -498,10 +498,12 @@ function SessionListInner(props: SessionListProps) { {/* Worktree children drawer (when expanded) */} {hasWorktrees && worktreesExpanded && onToggleWorktreeExpanded && (
@@ -559,13 +561,13 @@ function SessionListInner(props: SessionListProps) { ); - // Wrap in left-bordered container for flat/ungrouped sessions with worktrees - // Use ml-3 to align left edge, mr-3 minus the extra px-1 from ungrouped (px-4 vs px-3) + // Wrap in start-bordered container for flat/ungrouped sessions with worktrees + // Use ms-3 to align start edge, me-2 minus the extra px-1 from ungrouped (px-4 vs px-3) if (needsWorktreeWrapper) { return (
{content} @@ -594,7 +596,7 @@ function SessionListInner(props: SessionListProps) {
)} @@ -660,7 +662,7 @@ function SessionListInner(props: SessionListProps) { )} {/* Global LIVE Toggle */} -
+
{FEATURE_FLAGS.LLM_SETTINGS && ( )}
{testResult.status && ( @@ -509,9 +520,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
)} -

- Test sends a simple prompt to verify connectivity and configuration -

+

{t('llm.test_help')}

)} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 629ac0d4b7..2f3e5083ef 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -1,10 +1,43 @@ { + "modal": { + "aria_label": "Settings" + }, + "tabs": { + "general": "General", + "display": "Display", + "llm": "LLM", + "shortcuts": "Shortcuts", + "themes": "Themes", + "notifications": "Notifications", + "notifications_short": "Notify", + "ai_commands": "AI Commands", + "ssh_hosts": "SSH Hosts", + "encore_features": "Encore Features" + }, "general": { "title": "General", "theme_label": "Theme", "language_label": "Language", "language_description": "Select your preferred display language" }, + "llm": { + "provider_label": "LLM Provider", + "provider_openrouter": "OpenRouter", + "provider_anthropic": "Anthropic", + "provider_ollama": "Ollama (Local)", + "model_slug_label": "Model Slug", + "api_key_label": "API Key", + "api_key_help": "Keys are stored locally in ~/.maestro/settings.json", + "test_connection_button": "Test Connection", + "testing_connection_button": "Testing Connection...", + "test_help": "Test sends a simple prompt to verify connectivity and configuration", + "api_key_required_error": "API key is required for {{provider}}", + "connection_success": "Successfully connected to {{provider}}!", + "api_error": "{{provider}} API error: {{status}}", + "ollama_api_error": "Ollama API error: {{status}}. Make sure Ollama is running locally.", + "invalid_response_error": "Invalid response from {{provider}}", + "connection_failed_error": "Connection failed" + }, "encore": { "title": "Encore Features", "virtuosos_title": "Virtuosos" From a29a101636d56e780f56735c5f022186fd3218f2 Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 13:13:11 -0400 Subject: [PATCH 24/92] MAESTRO: extract all hardcoded strings from EncoreTab.tsx to i18n Replace 20+ hardcoded user-facing strings in EncoreTab with t() calls using the 'settings' namespace. Strings extracted include: section header, description, Director's Notes title/badge/description, provider selection labels, agent detection states, customize button, configuration header, lookback period labels and help text. All new keys added to en/settings.json under encore.director_notes.* with interpolation support for dynamic values ({{name}}, {{days}}). Added react-i18next mock to EncoreTab test using actual English locale for translation resolution. All 56 tests pass. Co-Authored-By: Claude Opus 4.6 --- .../Settings/tabs/EncoreTab.test.tsx | 36 ++++++++++++ .../components/Settings/tabs/EncoreTab.tsx | 56 ++++++++++--------- src/shared/i18n/locales/en/settings.json | 24 ++++++++ 3 files changed, 89 insertions(+), 27 deletions(-) diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 681edd5a9c..3577fafb4c 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -24,6 +24,42 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; import { EncoreTab } from '../../../../../renderer/components/Settings/tabs/EncoreTab'; import type { Theme, AgentConfig } from '../../../../../renderer/types'; +import settingsEn from '../../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); // Mock AgentConfigPanel to avoid deep rendering vi.mock('../../../../../renderer/components/shared/AgentConfigPanel', () => ({ diff --git a/src/renderer/components/Settings/tabs/EncoreTab.tsx b/src/renderer/components/Settings/tabs/EncoreTab.tsx index ef6940b43a..561a502ca1 100644 --- a/src/renderer/components/Settings/tabs/EncoreTab.tsx +++ b/src/renderer/components/Settings/tabs/EncoreTab.tsx @@ -5,6 +5,7 @@ * Director's Notes configuration (provider selection, agent config, lookback period). */ +import { useTranslation } from 'react-i18next'; import { Clapperboard, ChevronDown, Settings, Check } from 'lucide-react'; import { useSettings } from '../../../hooks'; import { useAgentConfiguration } from '../../../hooks/agent/useAgentConfiguration'; @@ -19,6 +20,7 @@ export interface EncoreTabProps { } export function EncoreTab({ theme, isOpen }: EncoreTabProps) { + const { t } = useTranslation('settings'); const { encoreFeatures, setEncoreFeatures, directorNotesSettings, setDirectorNotesSettings } = useSettings(); @@ -68,13 +70,10 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { {/* Encore Features Header */}

- Encore Features + {t('encore.title')}

- Optional features that extend Maestro's capabilities. Enable the ones you want. Disabled - features are completely hidden from shortcuts, menus, and the command palette. - Contributors building new features should consider gating them here to keep the core - experience focused. + {t('encore.description')}

@@ -110,7 +109,7 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { className="text-sm font-bold flex items-center gap-2" style={{ color: theme.colors.textMain }} > - Director's Notes + {t('encore.director_notes.title')} - Beta + {t('encore.director_notes.badge_beta')}
- Unified history view and AI-generated synopsis across all sessions + {t('encore.director_notes.description')}
@@ -155,7 +154,7 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { className="block text-xs font-bold opacity-70 uppercase mb-2" style={{ color: theme.colors.textMain }} > - Synopsis Provider + {t('encore.director_notes.provider_label')}
{ac.isDetecting ? ( @@ -168,13 +167,12 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { }} /> - Detecting agents... + {t('encore.director_notes.detecting_agents')}
) : dnAvailableTiles.length === 0 ? (
- No agents available. Please install Claude Code, OpenCode, Codex, or Factory - Droid. + {t('encore.director_notes.no_agents')}
) : (
@@ -188,14 +186,14 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { borderColor: theme.colors.border, color: theme.colors.textMain, }} - aria-label="Select synopsis provider agent" + aria-label={t('encore.director_notes.provider_aria_label')} > {dnAvailableTiles.map((tile) => { const isBeta = isBetaAgent(tile.id); return ( ); })} @@ -216,10 +214,10 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { ? `${theme.colors.accent}10` : 'transparent', }} - title="Customize provider settings" + title={t('encore.director_notes.customize_title')} > - Customize + {t('encore.director_notes.customize_button')} {ac.hasCustomization && (
- {dnSelectedTile.name} Configuration + {t('encore.director_notes.configuration_header', { + name: dnSelectedTile.name, + })} {ac.hasCustomization && (
- Customized + {t('encore.director_notes.customized')}
)} @@ -322,7 +322,7 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { )}

- The AI agent used to generate synopsis summaries + {t('encore.director_notes.provider_help')}

@@ -332,7 +332,9 @@ export function EncoreTab({ theme, isOpen }: EncoreTabProps) { className="block text-xs font-bold mb-2" style={{ color: theme.colors.textMain }} > - Default Lookback Period: {directorNotesSettings.defaultLookbackDays} days + {t('encore.director_notes.lookback_label', { + days: directorNotesSettings.defaultLookbackDays, + })}
- 1 day - 7 - 14 - 30 - 60 - 90 days + {t('encore.director_notes.lookback_1_day')} + {t('encore.director_notes.lookback_7')} + {t('encore.director_notes.lookback_14')} + {t('encore.director_notes.lookback_30')} + {t('encore.director_notes.lookback_60')} + {t('encore.director_notes.lookback_90_days')}

- How far back to look when generating notes (can be adjusted per-report) + {t('encore.director_notes.lookback_help')}

diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 2f3e5083ef..254423e7a0 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -40,6 +40,30 @@ }, "encore": { "title": "Encore Features", + "description": "Optional features that extend Maestro's capabilities. Enable the ones you want. Disabled features are completely hidden from shortcuts, menus, and the command palette. Contributors building new features should consider gating them here to keep the core experience focused.", + "director_notes": { + "title": "Director's Notes", + "badge_beta": "Beta", + "description": "Unified history view and AI-generated synopsis across all sessions", + "provider_label": "Synopsis Provider", + "detecting_agents": "Detecting agents...", + "no_agents": "No agents available. Please install Claude Code, OpenCode, Codex, or Factory Droid.", + "beta_suffix": "(Beta)", + "provider_aria_label": "Select synopsis provider agent", + "customize_title": "Customize provider settings", + "customize_button": "Customize", + "configuration_header": "{{name}} Configuration", + "customized": "Customized", + "provider_help": "The AI agent used to generate synopsis summaries", + "lookback_label": "Default Lookback Period: {{days}} days", + "lookback_1_day": "1 day", + "lookback_7": "7", + "lookback_14": "14", + "lookback_30": "30", + "lookback_60": "60", + "lookback_90_days": "90 days", + "lookback_help": "How far back to look when generating notes (can be adjusted per-report)" + }, "virtuosos_title": "Virtuosos" } } From eca354053dbc5aa12f28f184af2ccac9f51810da Mon Sep 17 00:00:00 2001 From: openasocket Date: Wed, 11 Mar 2026 14:38:11 -0400 Subject: [PATCH 25/92] MAESTRO: extract all hardcoded strings from settings-adjacent shared components to i18n Extract 100+ user-facing strings from 8 settings-adjacent components: - Settings/EnvVarsEditor.tsx (labels, placeholders, validation messages) - Settings/IgnorePatternsSection.tsx (labels, errors, button text) - Settings/SshRemoteIgnoreSection.tsx (title, description) - Settings/SshRemotesSection.tsx (section labels, badges, button titles) - Settings/SshRemoteModal.tsx (form labels, validation, status messages) - shared/AgentConfigPanel.tsx (path/args labels, env var section) - shared/AgentSelector.tsx (status badges, empty state) - shared/SshRemoteSelector.tsx (labels, status indicators) All keys added to en/settings.json under env_editor.*, ignore_patterns.*, ssh_remote_ignore.*, ssh_remotes.*, ssh_modal.*, agent_config.*, agent_selector.*, and ssh_selector.* namespaces. Added react-i18next mock to EnvVarsEditor and AgentConfigPanel test files. All 433 settings-related tests pass. Co-Authored-By: Claude Opus 4.6 --- .../Settings/EnvVarsEditor.test.tsx | 34 ++ .../Settings/tabs/DisplayTab.test.tsx | 36 ++ .../Settings/tabs/GeneralTab.test.tsx | 36 ++ .../Settings/tabs/ShortcutsTab.test.tsx | 37 ++ .../Settings/tabs/ThemeTab.test.tsx | 36 ++ .../components/SettingsModal.test.tsx | 37 ++ .../shared/AgentConfigPanel.test.tsx | 34 ++ .../components/Settings/EnvVarsEditor.tsx | 22 +- .../Settings/IgnorePatternsSection.tsx | 26 +- .../Settings/SshRemoteIgnoreSection.tsx | 6 +- .../components/Settings/SshRemoteModal.tsx | 100 ++--- .../components/Settings/SshRemotesSection.tsx | 35 +- .../components/Settings/tabs/DisplayTab.tsx | 87 +++-- .../components/Settings/tabs/GeneralTab.tsx | 348 +++++++++--------- .../components/Settings/tabs/ShortcutsTab.tsx | 16 +- .../components/Settings/tabs/ThemeTab.tsx | 6 +- .../components/shared/AgentConfigPanel.tsx | 55 +-- .../components/shared/AgentSelector.tsx | 16 +- .../components/shared/SshRemoteSelector.tsx | 28 +- src/shared/i18n/locales/en/settings.json | 317 +++++++++++++++- 20 files changed, 949 insertions(+), 363 deletions(-) diff --git a/src/__tests__/renderer/components/Settings/EnvVarsEditor.test.tsx b/src/__tests__/renderer/components/Settings/EnvVarsEditor.test.tsx index 12abab2e9d..cfcab5e6ca 100644 --- a/src/__tests__/renderer/components/Settings/EnvVarsEditor.test.tsx +++ b/src/__tests__/renderer/components/Settings/EnvVarsEditor.test.tsx @@ -14,6 +14,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { EnvVarsEditor } from '../../../../renderer/components/Settings/EnvVarsEditor'; import type { Theme } from '../../../../renderer/types'; +import settingsEn from '../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); const mockTheme: Theme = { id: 'dracula', diff --git a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx index 968d1c5c26..eb19f8e569 100644 --- a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx @@ -23,6 +23,42 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { DisplayTab } from '../../../../../renderer/components/Settings/tabs/DisplayTab'; import type { Theme } from '../../../../../renderer/types'; +import settingsEn from '../../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); // --- Mock setters (module-level for assertion access) --- const mockSetFontFamily = vi.fn(); diff --git a/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx index e40bd1db9e..09d0a2c9a8 100644 --- a/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/GeneralTab.test.tsx @@ -27,6 +27,42 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { GeneralTab } from '../../../../../renderer/components/Settings/tabs/GeneralTab'; import type { Theme, ShellInfo } from '../../../../../renderer/types'; +import settingsEn from '../../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); // Mock platformUtils vi.mock('../../../../../renderer/utils/platformUtils', () => ({ diff --git a/src/__tests__/renderer/components/Settings/tabs/ShortcutsTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/ShortcutsTab.test.tsx index 4ed2e7e7b0..ff8740f9bb 100644 --- a/src/__tests__/renderer/components/Settings/tabs/ShortcutsTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/ShortcutsTab.test.tsx @@ -21,6 +21,43 @@ vi.mock('../../../../../renderer/utils/shortcutFormatter', () => ({ formatShortcutKeys: vi.fn((keys: string[]) => keys.join('+')), })); +import settingsEn from '../../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); + const mockSetShortcuts = vi.fn(); const mockSetTabShortcuts = vi.fn(); diff --git a/src/__tests__/renderer/components/Settings/tabs/ThemeTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/ThemeTab.test.tsx index 39f0f0e4f8..3fc754c7c1 100644 --- a/src/__tests__/renderer/components/Settings/tabs/ThemeTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/ThemeTab.test.tsx @@ -15,6 +15,42 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { ThemeTab } from '../../../../../renderer/components/Settings/tabs/ThemeTab'; import type { Theme } from '../../../../../renderer/types'; +import settingsEn from '../../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); const mockSetActiveThemeId = vi.fn(); const mockSetCustomThemeColors = vi.fn(); diff --git a/src/__tests__/renderer/components/SettingsModal.test.tsx b/src/__tests__/renderer/components/SettingsModal.test.tsx index ab4aa7e614..d834b896f6 100644 --- a/src/__tests__/renderer/components/SettingsModal.test.tsx +++ b/src/__tests__/renderer/components/SettingsModal.test.tsx @@ -72,6 +72,43 @@ vi.mock('../../../renderer/components/CustomThemeBuilder', () => ({ ), })); +import settingsEn from '../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + // Strip namespace prefix (e.g., 'settings:encore.title' → 'encore.title') + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; // Fallback to key if not found + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + // Handle interpolation (e.g., {{name}}, {{days}}) + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); + // Shared mock fns so tests can assert on useSettings setters const mockSetActiveThemeId = vi.fn(); const mockSetCustomThemeColors = vi.fn(); diff --git a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx index c9a143b29e..3ed8196bb5 100644 --- a/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx +++ b/src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx @@ -9,6 +9,40 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { AgentConfigPanel } from '../../../../renderer/components/shared/AgentConfigPanel'; import type { Theme, AgentConfig } from '../../../../renderer/types'; +import settingsEn from '../../../../shared/i18n/locales/en/settings.json'; + +// Mock react-i18next to resolve keys from actual English translations +vi.mock('react-i18next', () => { + const resolve = (key: string): string => { + const bareKey = key.includes(':') ? key.split(':').slice(1).join(':') : key; + const parts = bareKey.split('.'); + let value: unknown = settingsEn; + for (const part of parts) { + if (value && typeof value === 'object' && part in (value as Record)) { + value = (value as Record)[part]; + } else { + return key; + } + } + return typeof value === 'string' ? value : key; + }; + + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + let result = resolve(key); + if (opts) { + for (const [k, v] of Object.entries(opts)) { + result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)); + } + } + return result; + }, + i18n: { language: 'en' }, + ready: true, + }), + }; +}); // Mock lucide-react icons vi.mock('lucide-react', () => ({ diff --git a/src/renderer/components/Settings/EnvVarsEditor.tsx b/src/renderer/components/Settings/EnvVarsEditor.tsx index 5716c562d0..710d3a03d4 100644 --- a/src/renderer/components/Settings/EnvVarsEditor.tsx +++ b/src/renderer/components/Settings/EnvVarsEditor.tsx @@ -12,6 +12,7 @@ */ import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Plus, Trash2 } from 'lucide-react'; import type { Theme } from '../../types'; @@ -28,6 +29,7 @@ export interface EnvVarsEditorProps { } export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps) { + const { t } = useTranslation('settings'); // Convert object to array with stable IDs for editing const [entries, setEntries] = useState(() => { return Object.entries(envVars).map(([key, value], index) => ({ @@ -46,7 +48,7 @@ export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps } // Check for valid variable name format (alphanumeric and underscore) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.key)) { - return `Invalid variable name: only letters, numbers, and underscores allowed and must not start with a number.`; + return t('env_editor.invalid_name'); } // Check if value contains special characters that might need quoting if ( @@ -55,7 +57,7 @@ export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps !entry.value.startsWith('"') && !entry.value.startsWith("'") ) { - return `Invalid value: contains disallowed special characters; quote or escape them if you intend to include them.`; + return t('env_editor.invalid_value'); } return null; }; @@ -144,7 +146,7 @@ export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps return (
-
Environment Variables (optional)
+
{t('env_editor.label')}
{entries.map((entry) => { const error = validationErrors[entry.id]; @@ -155,7 +157,7 @@ export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps type="text" value={entry.key} onChange={(e) => updateEntry(entry.id, 'key', e.target.value)} - placeholder="VARIABLE" + placeholder={t('env_editor.variable_placeholder')} className={`flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono ${ entry.key.trim() && !validateEntry({ id: entry.id, key: entry.key, value: entry.value }) @@ -174,14 +176,14 @@ export function EnvVarsEditor({ envVars, setEnvVars, theme }: EnvVarsEditorProps type="text" value={entry.value} onChange={(e) => updateEntry(entry.id, 'value', e.target.value)} - placeholder="value" + placeholder={t('env_editor.value_placeholder')} className="flex-[2] p-2 rounded border bg-transparent outline-none text-xs font-mono" style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} />
-

- Environment variables passed to all terminal sessions and AI agent processes. -

+

{t('env_editor.description')}

{Object.keys(envVars).length > 0 && (

- ✓ Valid ({Object.keys(envVars).length} variables loaded) + {t('env_editor.valid_count', { count: Object.keys(envVars).length })}

)}
diff --git a/src/renderer/components/Settings/IgnorePatternsSection.tsx b/src/renderer/components/Settings/IgnorePatternsSection.tsx index 08f6549cbc..a3dd901e06 100644 --- a/src/renderer/components/Settings/IgnorePatternsSection.tsx +++ b/src/renderer/components/Settings/IgnorePatternsSection.tsx @@ -18,6 +18,7 @@ */ import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import type { LucideIcon } from 'lucide-react'; import { FolderX, Plus, X, Check, FileText } from 'lucide-react'; import type { Theme } from '../../types'; @@ -60,6 +61,7 @@ export function IgnorePatternsSection({ onHonorGitignoreChange, onReset, }: IgnorePatternsSectionProps) { + const { t } = useTranslation('settings'); // Local state for the new pattern input const [newPattern, setNewPattern] = useState(''); const [inputError, setInputError] = useState(null); @@ -68,11 +70,11 @@ export function IgnorePatternsSection({ const handleAddPattern = useCallback(() => { const trimmedPattern = newPattern.trim(); if (!trimmedPattern) { - setInputError('Pattern cannot be empty'); + setInputError(t('ignore_patterns.error_empty')); return; } if (ignorePatterns.includes(trimmedPattern)) { - setInputError('Pattern already exists'); + setInputError(t('ignore_patterns.error_duplicate')); return; } onIgnorePatternsChange([...ignorePatterns, trimmedPattern]); @@ -122,7 +124,9 @@ export function IgnorePatternsSection({ {/* Content */}
-

File Indexing

+

+ {t('ignore_patterns.file_indexing')} +

{title}

{description}

@@ -148,12 +152,12 @@ export function IgnorePatternsSection({
- Honor .gitignore + {t('ignore_patterns.honor_gitignore')}

- When enabled, patterns from .gitignore files will also be excluded from indexing. + {t('ignore_patterns.honor_gitignore_description')}

)} @@ -162,7 +166,7 @@ export function IgnorePatternsSection({ {ignorePatterns.length > 0 && (

- Active patterns: + {t('ignore_patterns.active_patterns')}

{ignorePatterns.map((pattern) => ( @@ -181,7 +185,7 @@ export function IgnorePatternsSection({ onClick={() => handleRemovePattern(pattern)} className="p-0.5 rounded hover:bg-white/10 transition-colors ml-1" style={{ color: theme.colors.error }} - title="Remove pattern" + title={t('ignore_patterns.remove_pattern')} > @@ -198,7 +202,7 @@ export function IgnorePatternsSection({ style={{ borderColor: theme.colors.border }} >

- No ignore patterns configured. All folders will be indexed. + {t('ignore_patterns.no_patterns')}

)} @@ -214,7 +218,7 @@ export function IgnorePatternsSection({ setInputError(null); }} onKeyDown={handleKeyDown} - placeholder="Enter glob pattern (e.g., node_modules, *.log)" + placeholder={t('ignore_patterns.input_placeholder')} className="w-full px-3 py-2 rounded text-sm font-mono outline-none" style={{ backgroundColor: theme.colors.bgActivity, @@ -243,7 +247,7 @@ export function IgnorePatternsSection({ }} > - Add + {t('ignore_patterns.add')}
@@ -254,7 +258,7 @@ export function IgnorePatternsSection({ className="text-xs hover:underline" style={{ color: theme.colors.textDim }} > - Reset to defaults ({defaultsLabel}) + {t('ignore_patterns.reset_to_defaults', { defaults: defaultsLabel })}
diff --git a/src/renderer/components/Settings/SshRemoteIgnoreSection.tsx b/src/renderer/components/Settings/SshRemoteIgnoreSection.tsx index 1b0aeb3091..eb4b6568ed 100644 --- a/src/renderer/components/Settings/SshRemoteIgnoreSection.tsx +++ b/src/renderer/components/Settings/SshRemoteIgnoreSection.tsx @@ -17,6 +17,7 @@ */ import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import type { Theme } from '../../types'; import { IgnorePatternsSection } from './IgnorePatternsSection'; @@ -43,6 +44,7 @@ export function SshRemoteIgnoreSection({ honorGitignore, onHonorGitignoreChange, }: SshRemoteIgnoreSectionProps) { + const { t } = useTranslation('settings'); const handleReset = useCallback(() => { onHonorGitignoreChange(true); }, [onHonorGitignoreChange]); @@ -50,8 +52,8 @@ export function SshRemoteIgnoreSection({ return ( { - if (!name.trim()) return 'Name is required'; - if (!host.trim()) return 'Host is required'; + if (!name.trim()) return t('ssh_modal.validation_name_required'); + if (!host.trim()) return t('ssh_modal.validation_host_required'); const portNum = parseInt(port, 10); if (isNaN(portNum) || portNum < 1 || portNum > 65535) { - return 'Port must be between 1 and 65535'; + return t('ssh_modal.validation_port_range'); } // Username and key are always optional - SSH will use defaults from config or ssh-agent return null; @@ -369,10 +372,10 @@ export function SshRemoteModal({ if (result.success) { onClose(); } else { - setError(result.error || 'Failed to save configuration'); + setError(result.error || t('ssh_modal.failed_save')); } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save configuration'); + setError(err instanceof Error ? err.message : t('ssh_modal.failed_save')); } finally { setSaving(false); } @@ -398,19 +401,19 @@ export function SshRemoteModal({ if (result.success && result.result) { setTestResult({ success: true, - message: 'Connection successful!', + message: t('ssh_modal.connection_success'), hostname: result.result.remoteInfo?.hostname, }); } else { setTestResult({ success: false, - message: result.error || 'Connection failed', + message: result.error || t('ssh_modal.connection_failed'), }); } } catch (err) { setTestResult({ success: false, - message: err instanceof Error ? err.message : 'Connection test failed', + message: err instanceof Error ? err.message : t('ssh_modal.connection_test_failed'), }); } finally { setTesting(false); @@ -436,7 +439,8 @@ export function SshRemoteModal({ if (!isOpen) return null; - const modalTitle = title || (initialConfig ? 'Edit SSH Remote' : 'Add SSH Remote'); + const modalTitle = + title || (initialConfig ? t('ssh_modal.title_edit') : t('ssh_modal.title_add')); const hasSshConfigHosts = sshConfigHosts.length > 0; return ( @@ -466,10 +470,10 @@ export function SshRemoteModal({ {testing ? ( <> - Testing... + {t('ssh_modal.testing')} ) : ( - 'Test Connection' + t('ssh_modal.test_connection') )} )} @@ -478,7 +482,7 @@ export function SshRemoteModal({ theme={theme} onCancel={onClose} onConfirm={handleSave} - confirmLabel={saving ? 'Saving...' : 'Save'} + confirmLabel={saving ? t('ssh_modal.saving') : t('ssh_modal.save')} confirmDisabled={!isValid || saving} />
@@ -519,7 +523,7 @@ export function SshRemoteModal({
{testResult.message}
{testResult.hostname && (
- Remote hostname: {testResult.hostname} + {t('ssh_modal.remote_hostname', { hostname: testResult.hostname })}
)}
@@ -538,12 +542,16 @@ export function SshRemoteModal({
- Import from SSH Config + {t('ssh_modal.import_header')}

- {sshConfigHosts.length} host{sshConfigHosts.length !== 1 ? 's' : ''} found in - ~/.ssh/config + {t( + sshConfigHosts.length !== 1 + ? 'ssh_modal.hosts_found_plural' + : 'ssh_modal.hosts_found', + { count: sshConfigHosts.length } + )}

@@ -576,7 +584,7 @@ export function SshRemoteModal({ }} onKeyDown={handleDropdownKeyDown} role="listbox" - aria-label="SSH config hosts" + aria-label={t('ssh_modal.ssh_config_hosts_aria')} tabIndex={0} > {/* Filter input */} @@ -587,7 +595,7 @@ export function SshRemoteModal({ value={sshConfigFilter} onChange={(e) => handleSshConfigFilterChange(e.target.value)} onKeyDown={handleDropdownKeyDown} - placeholder="Type to filter..." + placeholder={t('ssh_modal.type_to_filter')} className="w-full px-2 py-1 rounded text-sm bg-transparent outline-none" style={{ color: theme.colors.textMain, @@ -602,7 +610,7 @@ export function SshRemoteModal({ className="px-3 py-2 text-sm text-center" style={{ color: theme.colors.textDim }} > - No hosts match filter + {t('ssh_modal.no_hosts_match')}
) : ( filteredSshConfigHosts.map((configHost, index) => ( @@ -622,7 +630,7 @@ export function SshRemoteModal({ >
{configHost.host}
- {getSshConfigHostSummary(configHost)} + {getSshConfigHostSummary(configHost) || t('ssh_modal.no_details')}
)) @@ -645,7 +653,7 @@ export function SshRemoteModal({ > - Imported from: {sshConfigHost} + {t('ssh_modal.imported_from')} {sshConfigHost} @@ -665,11 +673,11 @@ export function SshRemoteModal({ {/* Host and Port */} @@ -677,18 +685,18 @@ export function SshRemoteModal({
{/* Private Key Path */} {/* Environment Variables */} @@ -726,7 +734,7 @@ export function SshRemoteModal({ className="text-xs font-bold opacity-70 uppercase" style={{ color: theme.colors.textMain }} > - Environment Variables (optional) + {t('ssh_modal.env_vars_label')}
@@ -747,7 +755,7 @@ export function SshRemoteModal({ type="text" value={entry.key} onChange={(e) => updateEnvVar(entry.id, 'key', e.target.value)} - placeholder="VARIABLE" + placeholder={t('ssh_modal.variable_placeholder')} className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono" style={{ borderColor: theme.colors.border, @@ -761,7 +769,7 @@ export function SshRemoteModal({ type="text" value={entry.value} onChange={(e) => updateEnvVar(entry.id, 'value', e.target.value)} - placeholder="value" + placeholder={t('ssh_modal.value_placeholder')} className="flex-[2] p-2 rounded border bg-transparent outline-none text-xs font-mono" style={{ borderColor: theme.colors.border, @@ -772,7 +780,7 @@ export function SshRemoteModal({ type="button" onClick={() => removeEnvVar(entry.id)} className="p-2 rounded hover:bg-white/10 transition-colors" - title="Remove variable" + title={t('ssh_modal.remove_variable')} style={{ color: theme.colors.textDim }} > @@ -783,7 +791,7 @@ export function SshRemoteModal({ )}

- Environment variables passed to agents running on this remote host + {t('ssh_modal.env_vars_help')}

@@ -797,10 +805,10 @@ export function SshRemoteModal({ >
- Enable this remote + {t('ssh_modal.enable_remote')}
- Disabled remotes won't be available for selection + {t('ssh_modal.disabled_description')}
); @@ -156,12 +158,11 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { {/* Content */}
-

Remote Execution

-

SSH Remote Hosts

-

- Configure remote hosts where AI agents can be executed via SSH. This allows running - agents on powerful remote machines or servers with specific tools installed. +

+ {t('ssh_remotes.section_label')}

+

{t('ssh_remotes.title')}

+

{t('ssh_remotes.description')}

{/* Error Display */} {error && ( @@ -218,7 +219,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { color: theme.colors.accent, }} > - Default + {t('ssh_remotes.badge_default')} )} {!config.enabled && ( @@ -229,7 +230,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { color: theme.colors.warning, }} > - Disabled + {t('ssh_remotes.badge_disabled')} )}
@@ -267,7 +268,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { disabled={isTesting || !config.enabled} className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50" style={{ color: theme.colors.textDim }} - title="Test connection" + title={t('ssh_remotes.test_connection')} > {isTesting ? ( @@ -289,7 +290,11 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { style={{ color: isDefault ? theme.colors.accent : theme.colors.textDim, }} - title={isDefault ? 'Remove as default' : 'Set as default'} + title={ + isDefault + ? t('ssh_remotes.remove_default') + : t('ssh_remotes.set_default') + } > @@ -300,7 +305,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { onClick={() => handleEdit(config)} className="p-1.5 rounded hover:bg-white/10 transition-colors" style={{ color: theme.colors.textDim }} - title="Edit" + title={t('ssh_remotes.edit')} > @@ -312,7 +317,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { disabled={isDeleting} className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50" style={{ color: theme.colors.error }} - title="Delete" + title={t('ssh_remotes.delete')} > {isDeleting ? ( @@ -339,10 +344,10 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { style={{ color: theme.colors.textDim }} />

- No SSH remotes configured + {t('ssh_remotes.no_remotes')}

- Add a remote host to run AI agents on external machines + {t('ssh_remotes.no_remotes_hint')}

)} @@ -358,7 +363,7 @@ export function SshRemotesSection({ theme }: SshRemotesSectionProps) { }} > - Add SSH Remote + {t('ssh_remotes.add_button')}
diff --git a/src/renderer/components/Settings/tabs/DisplayTab.tsx b/src/renderer/components/Settings/tabs/DisplayTab.tsx index e81e20154f..63c23f5f33 100644 --- a/src/renderer/components/Settings/tabs/DisplayTab.tsx +++ b/src/renderer/components/Settings/tabs/DisplayTab.tsx @@ -7,6 +7,7 @@ */ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Sparkles, AlertTriangle, AppWindow } from 'lucide-react'; import { useSettings } from '../../../hooks'; import type { Theme } from '../../../types'; @@ -48,6 +49,7 @@ export function DisplayTab({ theme }: DisplayTabProps) { localHonorGitignore, setLocalHonorGitignore, } = useSettings(); + const { t } = useTranslation('settings'); const [systemFonts, setSystemFonts] = useState([]); const [customFonts, setCustomFonts] = useState([]); @@ -114,13 +116,15 @@ export function DisplayTab({ theme }: DisplayTabProps) { {/* Font Size */}
-
Font Size
+
+ {t('display.font_size_header')} +
- Terminal Width (Columns) + {t('display.terminal_width_header')}
-
Maximum Log Buffer
+
+ {t('display.log_buffer_header')} +
-

- Maximum number of log messages to keep in memory. Older logs are automatically removed. -

+

{t('display.log_buffer_help')}

{/* Max Output Lines */}
- Max Output Lines per Response + {t('display.output_lines_header')}
-

- Long outputs will be collapsed into a scrollable window. Set to "All" to always show full - output. -

+

{t('display.output_lines_help')}

{/* Message Alignment */}
- User Message Alignment + {t('display.alignment_header')}
-

- Position your messages on the left or right side of the chat. AI responses appear on the - opposite side. -

+

{t('display.alignment_help')}

{/* Window Chrome Settings */} @@ -205,7 +203,7 @@ export function DisplayTab({ theme }: DisplayTabProps) { style={{ color: theme.colors.textDim }} > - Window Chrome + {t('display.window_chrome_header')}

- Use native title bar + {t('display.native_title_bar_title')}

- Use the OS native title bar instead of Maestro's custom title bar. Requires - restart. + {t('display.native_title_bar_description')}

@@ -360,7 +355,7 @@ export function DisplayTab({ theme }: DisplayTabProps) {
- Context Window Warnings + {t('display.context_warnings_header')}
- Show context consumption warnings + {t('display.context_warnings_title')}
- Display warning banners when context window usage reaches configurable thresholds + {t('display.context_warnings_description')}
- Red warning threshold + {t('display.context_warnings_red')}
([]); @@ -221,7 +223,7 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) { }) .catch((err) => { console.error('Failed to load sync settings:', err); - setSyncError('Failed to load storage settings'); + setSyncError(t('general.storage_failed_load')); }); // Load stats database size and earliest timestamp @@ -281,18 +283,14 @@ export function GeneralTab({ theme, isOpen }: GeneralTabProps) {
- Conductor Profile (aka, About Me) + {t('general.conductor_profile_header')}
-

- Tell us a little about yourself so that agents created under Maestro know how to work and - communicate with you. As the conductor, you orchestrate the symphony of AI agents. - (Optional, max 1000 characters) -

+

{t('general.conductor_profile_description')}