Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions ui/desktop/src/components/Layout/NavigationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@ import { AppEvents } from '../../constants/events';
import { CondensedRenderer } from './CondensedRenderer';
import { ExpandedRenderer } from './ExpandedRenderer';
import { NavigationOverlay } from './navigation';
import { defineMessages, useIntl } from '../../i18n';
import type { SessionStatus, DragHandlers } from './navigation/types';

const i18n = defineMessages({
home: { id: 'navigationCustomization.itemHome', defaultMessage: 'Home' },
chat: { id: 'navigationCustomization.itemChat', defaultMessage: 'Chat' },
recipes: { id: 'navigationCustomization.itemRecipes', defaultMessage: 'Recipes' },
skills: { id: 'skillsView.skillsTitle', defaultMessage: 'Skills' },
apps: { id: 'navigationCustomization.itemApps', defaultMessage: 'Apps' },
scheduler: { id: 'navigationCustomization.itemScheduler', defaultMessage: 'Scheduler' },
extensions: { id: 'navigationCustomization.itemExtensions', defaultMessage: 'Extensions' },
settings: { id: 'navigationCustomization.itemSettings', defaultMessage: 'Settings' },
});

export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const {
isNavExpanded,
Expand All @@ -26,6 +38,21 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {

const location = useLocation();
const { extensionsList } = useConfig();
const intl = useIntl();

const translatedLabels = useMemo(
() => ({
home: intl.formatMessage(i18n.home),
chat: intl.formatMessage(i18n.chat),
recipes: intl.formatMessage(i18n.recipes),
skills: intl.formatMessage(i18n.skills),
apps: intl.formatMessage(i18n.apps),
scheduler: intl.formatMessage(i18n.scheduler),
extensions: intl.formatMessage(i18n.extensions),
settings: intl.formatMessage(i18n.settings),
}),
[intl]
);

const appsExtensionEnabled = !!extensionsList?.find((ext) => ext.name === 'apps')?.enabled;

Expand All @@ -37,8 +64,12 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
.filter((item) => {
if (item.path === '/apps') return appsExtensionEnabled;
return true;
});
}, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled]);
})
.map((item) => ({
...item,
label: translatedLabels[item.id as keyof typeof translatedLabels] ?? item.label,
}));
}, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled, translatedLabels]);

const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);

Expand Down Expand Up @@ -202,7 +233,6 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {

if (isOverlayMode) {
if (effectiveNavigationStyle === 'expanded') {
// Expanded overlay uses its own AnimatePresence layout
return content;
}
return (
Expand Down
35 changes: 21 additions & 14 deletions ui/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
export { defineMessages, useIntl } from 'react-intl';

/** The set of locales that have translation catalogs. */
const SUPPORTED_LOCALES = new Set(['en']);
const SUPPORTED_LOCALES = new Set(['en', 'zh', 'zh-CN']);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize underscore locale tags before matching locales

With zh/zh-CN now listed as supported, GOOSE_LOCALE=zh_CN becomes a practical input, but getLocale() still compares the raw tag and only derives a base via split('-'), so zh_CN misses both checks and falls back to English. Meanwhile isZhLocale() in main.ts already normalizes _ to -, so this produces a mixed-language app (Chinese native menu, English renderer). Normalize candidate tags in getLocale() before matching/canonicalizing to keep renderer and menu localization consistent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Addressed in 1acf963.

I updated i18n locale resolution to normalize underscore tags (e.g. zh_CN -> zh-CN) before canonicalization/matching, so renderer locale selection stays consistent with native menu locale handling.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep zh locales unsupported until catalogs exist

Adding 'zh' and 'zh-CN' to SUPPORTED_LOCALES makes getLocale() return those values, so the renderer now calls loadMessages('zh' | 'zh-CN'); however the repo only ships src/i18n/messages/en.json (and i18n-compile only compiles files in that folder), so those imports fall back to {} and the app runs with Chinese locale formatting but English UI strings plus a warning on startup. This is a user-visible regression for anyone setting GOOSE_LOCALE=zh*; either commit the corresponding zh catalog(s) or defer advertising these locales as supported.

Useful? React with 👍 / 👎.


/**
* Detect the user's preferred locale.
Expand All @@ -36,23 +36,30 @@ export function getLocale(): { locale: string; messageLocale: string } {
candidates.push(navigator.language);
}

for (const tag of candidates) {
for (const candidate of candidates) {
const normalized = candidate.replace(/_/g, '-');

let canonical: string | undefined;
try {
[canonical] = Intl.getCanonicalLocales(normalized);
} catch {
canonical = undefined;
}

const resolved = canonical ?? normalized;

// Exact match first
if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag };
if (SUPPORTED_LOCALES.has(resolved)) {
return { locale: resolved, messageLocale: resolved };
}

// Try base language (e.g. "pt-BR" → "pt") for the catalog, but keep the
// full regional tag for formatting so date/number output respects the region.
const base = tag.split('-')[0];
const base = resolved.split('-')[0];
if (SUPPORTED_LOCALES.has(base)) {
// Validate the full tag is a well-formed BCP 47 locale before using it
// for formatting. Invalid tags (e.g. "en-") would cause RangeError in
// Intl APIs, so fall back to the base language in that case.
let locale = base;
try {
[locale] = Intl.getCanonicalLocales(tag);
} catch {
// tag is not valid BCP 47 — use the base language instead
}
return { locale, messageLocale: base };
// If canonicalization fails, return the base locale so downstream Intl APIs
// never receive malformed tags like "en-".
return { locale: canonical ?? base, messageLocale: base };
}
}

Expand Down
105 changes: 98 additions & 7 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron';
import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron';
import {
app,
App,
Expand Down Expand Up @@ -1124,6 +1124,92 @@ const buildRecentFilesMenu = () => {
}));
};


type MenuKey = 'file' | 'edit' | 'view' | 'window' | 'help';

const MENU_LABEL_ALIASES: Record<MenuKey, string[]> = {
file: ['File', '文件'],
edit: ['Edit', '编辑'],
view: ['View', '视图'],
window: ['Window', '窗口'],
help: ['Help', '帮助'],
};

const MENU_ZH_TRANSLATIONS: Record<string, string> = {
File: '文件',
Edit: '编辑',
View: '视图',
Window: '窗口',
Help: '帮助',
About: '关于',
'About Goose': '关于 Goose',
Reload: '重新加载',
'Force Reload': '强制重新加载',
'Toggle Developer Tools': '切换开发者工具',
Undo: '撤销',
Redo: '重做',
Cut: '剪切',
Copy: '复制',
Paste: '粘贴',
Delete: '删除',
'Select All': '全选',
Find: '查找',
'Find…': '查找…',
'Find Next': '查找下一个',
'Find Previous': '查找上一个',
'Use Selection for Find': '使用所选内容查找',
Settings: '设置',
'New Window': '新建窗口',
'New Chat': '新建聊天',
'New Chat Window': '新建聊天窗口',
'Open Directory...': '打开目录…',
'Recent Directories': '最近目录',
'Focus Goose Window': '聚焦 Goose 窗口',
'Quick Launcher': '快速启动器',
'Always on Top': '窗口置顶',
'Toggle Navigation': '切换导航栏',
Close: '关闭',
Minimize: '最小化',
Zoom: '缩放',
'Bring All to Front': '全部置于前台',
'Actual Size': '实际大小',
'Zoom In': '放大',
'Zoom Out': '缩小',
'Toggle Full Screen': '切换全屏',
};

function isZhLocale(): boolean {
const configuredLocale = (process.env.GOOSE_LOCALE ?? '').trim().toLowerCase().replace('_', '-');
const locale = configuredLocale || (app.getLocale()?.toLowerCase() ?? '');
return locale === 'zh' || locale.startsWith('zh-');
}

function findTopMenu(menu: Menu | null, key: MenuKey): MenuItem | undefined {
return menu?.items.find((item) => MENU_LABEL_ALIASES[key].includes(item.label));
}

function translateMenuLabel(label: string): string {
const hasAmpersand = label.includes('&');
const normalized = label.replace(/&/g, '').trim();

if (normalized.startsWith('Version ')) {
const suffix = normalized.slice('Version '.length);
return `${hasAmpersand ? '&' : ''}版本 ${suffix}`;
}

const translated = MENU_ZH_TRANSLATIONS[normalized];
if (!translated) return label;
return `${hasAmpersand ? '&' : ''}${translated}`;
}

function localizeMenuTree(menu: Menu): void {
for (const item of menu.items) {
item.label = translateMenuLabel(item.label);
if (item.submenu) {
localizeMenuTree(item.submenu);
}
}
}
const openDirectoryDialog = async (): Promise<OpenDialogReturnValue> => {
// Get the current working directory from the focused window
let defaultPath: string | undefined;
Expand Down Expand Up @@ -1919,7 +2005,7 @@ async function appMain() {
appMenu.submenu.insert(1, new MenuItem({ type: 'separator' }));
}

const editMenu = menu?.items.find((item) => item.label === 'Edit');
const editMenu = findTopMenu(menu, 'edit');
if (editMenu?.submenu) {
const selectAllIndex = editMenu.submenu.items.findIndex((item) => item.label === 'Select All');

Expand Down Expand Up @@ -1968,7 +2054,7 @@ async function appMain() {
);
}

const fileMenu = menu?.items.find((item) => item.label === 'File');
const fileMenu = findTopMenu(menu, 'file');

if (fileMenu?.submenu) {
// Use a counter to track the actual insertion index
Expand Down Expand Up @@ -2051,15 +2137,15 @@ async function appMain() {
}

if (menu) {
let windowMenu = menu.items.find((item) => item.label === 'Window');
let windowMenu = findTopMenu(menu, 'window');

if (!windowMenu) {
windowMenu = new MenuItem({
label: 'Window',
submenu: Menu.buildFromTemplate([]),
});

const helpMenuIndex = menu.items.findIndex((item) => item.label === 'Help');
const helpMenuIndex = menu.items.findIndex((item) => ['Help', '帮助'].includes(item.label));
if (helpMenuIndex >= 0) {
menu.items.splice(helpMenuIndex, 0, windowMenu);
} else {
Expand Down Expand Up @@ -2095,7 +2181,7 @@ async function appMain() {
}
}

const viewMenu = menu.items.find((item) => item.label === 'View');
const viewMenu = findTopMenu(menu, 'view');
if (viewMenu?.submenu && shortcuts.toggleNavigation) {
viewMenu.submenu.append(new MenuItem({ type: 'separator' }));
viewMenu.submenu.append(
Expand All @@ -2115,7 +2201,7 @@ async function appMain() {

// on macOS, the topbar is hidden
if (menu && process.platform !== 'darwin') {
let helpMenu = menu.items.find((item) => item.label === 'Help');
let helpMenu = findTopMenu(menu, 'help');

// If Help menu doesn't exist, create it and add it to the menu
if (!helpMenu) {
Expand Down Expand Up @@ -2156,6 +2242,9 @@ async function appMain() {
}

if (menu) {
if (isZhLocale()) {
localizeMenuTree(menu);
}
Menu.setApplicationMenu(menu);
}

Expand Down Expand Up @@ -2549,3 +2638,5 @@ app.on('window-all-closed', () => {
app.quit();
}
});


Loading