Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,7 @@ export default function ChatInput({
data-testid="chat-input"
autoFocus
id="dynamic-textarea"
placeholder={isRecording ? '' : getNavigationShortcutText()}
placeholder={isRecording ? '' : getNavigationShortcutText(intl)}
value={displayValue}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
Expand Down Expand Up @@ -1514,7 +1514,7 @@ export default function ChatInput({
}`}
>
<Send className="w-4 h-4" />
<span className="text-sm">Send</span>
<span className="text-sm">{intl.formatMessage(i18n.send)}</span>
</Button>
</span>
</TooltipTrigger>
Expand Down
9 changes: 5 additions & 4 deletions ui/desktop/src/components/Layout/CondensedRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown, SessionsList } from './navigation';
import type { NavigationRendererProps } from './navigation/types';
import { getNavItemLabel } from '../../hooks/useNavigationItems';

const i18n = defineMessages({
newChat: {
Expand Down Expand Up @@ -161,7 +162,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
</div>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left flex-1">
{item.label}
{getNavItemLabel(item, intl)}
</span>
<div className="flex-shrink-0">
{isChatExpanded ? (
Expand Down Expand Up @@ -215,7 +216,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
<Icon className="w-5 h-5 flex-shrink-0" />
{!isCondensedIconOnly && (
<span className="text-sm font-medium text-left flex-1">
{item.label}
{getNavItemLabel(item, intl)}
</span>
)}
{!isCondensedIconOnly && item.getTag && (
Expand Down Expand Up @@ -305,7 +306,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
{getNavItemLabel(item, intl)}
</span>
</motion.button>
</DropdownMenuTrigger>
Expand Down Expand Up @@ -335,7 +336,7 @@ export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
{getNavItemLabel(item, intl)}
</span>
</motion.button>
)}
Expand Down
7 changes: 5 additions & 2 deletions ui/desktop/src/components/Layout/ExpandedRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown } from './navigation';
import type { NavigationRendererProps } from './navigation/types';
import { useIntl } from '../../i18n';
import { getNavItemLabel } from '../../hooks/useNavigationItems';

export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
isNavExpanded,
Expand All @@ -25,6 +27,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
drag,
navFocusRef,
}) => {
const intl = useIntl();
const [chatDropdownOpen, setChatDropdownOpen] = useState(false);
const [gridColumns, setGridColumns] = useState(2);
const [gridMeasured, setGridMeasured] = useState(false);
Expand Down Expand Up @@ -199,7 +202,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
<h2 className="font-light text-left text-xl">{getNavItemLabel(item, intl)}</h2>
</div>
</div>
</motion.div>
Expand Down Expand Up @@ -268,7 +271,7 @@ export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
<h2 className="font-light text-left text-xl">{getNavItemLabel(item, intl)}</h2>
</div>
</button>
</motion.div>
Expand Down
47 changes: 47 additions & 0 deletions ui/desktop/src/hooks/useNavigationItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Zap,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { defineMessages, type IntlShape, type MessageDescriptor } from 'react-intl';

export interface NavItem {
id: string;
Expand All @@ -31,6 +32,52 @@ export const NAV_ITEMS: NavItem[] = [
{ id: 'settings', path: '/settings', label: 'Settings', icon: Settings },
];

// Translation descriptors for nav labels. Kept here next to NAV_ITEMS so the two
// stay in sync. Reuses the existing `navigationCustomization.item*` ids that are
// also used by the "Customize Navigation" settings screen.
const navItemMessages = defineMessages({
home: {
id: 'navigationCustomization.itemHome',
defaultMessage: 'Home',
},
chat: {
id: 'navigationCustomization.itemChat',
defaultMessage: 'Chat',
},
recipes: {
id: 'navigationCustomization.itemRecipes',
defaultMessage: 'Recipes',
},
skills: {
id: 'navigationCustomization.itemSkills',
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',
},
});

const NAV_ITEM_MESSAGES: Record<string, MessageDescriptor> = navItemMessages;

/** Format a NavItem's label using the provided intl instance, falling back to `item.label`. */
export function getNavItemLabel(item: NavItem, intl: IntlShape): string {
const descriptor = NAV_ITEM_MESSAGES[item.id];
return descriptor ? intl.formatMessage(descriptor) : item.label;
}

export function getNavItemById(id: string): NavItem | undefined {
return NAV_ITEMS.find((item) => item.id === id);
}
24 changes: 11 additions & 13 deletions ui/desktop/src/i18n/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,43 @@ describe('getLocale', () => {
});

it('returns "en" as the default fallback', () => {
// navigator.language returns something unsupported
vi.stubGlobal('navigator', { language: 'xx-XX' });
// navigator.languages contains only unsupported tags
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});

it('preserves regional tag for formatting when base language is supported', () => {
vi.stubGlobal('navigator', { language: 'en-US' });
vi.stubGlobal('navigator', { languages: ['en-US'] });
expect(getLocale()).toEqual({ locale: 'en-US', messageLocale: 'en' });
});

it('returns exact match when navigator.language matches a supported locale', () => {
vi.stubGlobal('navigator', { language: 'en' });
it('returns exact match when navigator.languages contains a supported locale', () => {
vi.stubGlobal('navigator', { languages: ['en'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});

it('respects GOOSE_LOCALE over navigator.language', () => {
it('respects GOOSE_LOCALE over navigator.languages', () => {
mockAppConfig({ GOOSE_LOCALE: 'en' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});

it('preserves regional tag from GOOSE_LOCALE', () => {
mockAppConfig({ GOOSE_LOCALE: 'en-GB' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});

it('falls back to base language tag for message catalog', () => {
// "en-GB" should use "en" catalog but keep "en-GB" for formatting
vi.stubGlobal('navigator', { language: 'en-GB' });
vi.stubGlobal('navigator', { languages: ['en-GB'] });
expect(getLocale()).toEqual({ locale: 'en-GB', messageLocale: 'en' });
});

it('falls back to base language when locale tag is invalid BCP 47', () => {
// "en-" is not a valid BCP 47 tag and would cause RangeError in Intl APIs
mockAppConfig({ GOOSE_LOCALE: 'en-' });
vi.stubGlobal('navigator', { language: 'xx-XX' });
vi.stubGlobal('navigator', { languages: ['xx-XX'] });
expect(getLocale()).toEqual({ locale: 'en', messageLocale: 'en' });
});
});
Expand All @@ -72,9 +72,7 @@ describe('loadMessages', () => {
const { loadMessages } = await import('./index');
const messages = await loadMessages('xx');
expect(messages).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('No message catalog found')
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No message catalog found'));
warnSpy.mockRestore();
});
});
46 changes: 35 additions & 11 deletions ui/desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,37 @@
*
* Locale resolution order:
* 1. GOOSE_LOCALE config value (set via environment variable, passed through appConfig)
* 2. navigator.language (browser/OS locale)
* 2. navigator.languages (full accept-language list from OS/browser)
* 3. "en" (fallback)
*
* For Chinese: any Simplified Chinese tag (zh, zh-CN, zh-Hans, zh-Hans-CN, zh-SG, zh-MY)
* maps to the "zh-CN" catalog. Traditional variants (zh-TW, zh-HK, zh-Hant) are not yet
* translated and fall through to English.
*/

// Re-export react-intl utilities that components use directly
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-CN']);

/**
* Map Simplified Chinese aliases (zh, zh-Hans*, zh-SG, zh-MY) to "zh-CN".
* Traditional variants (zh-Hant*, zh-TW, zh-HK, zh-MO) and non-Chinese tags pass through unchanged.
*/
function resolveChineseAlias(tag: string): string {
const lower = tag.toLowerCase();
if (/^zh-(hant|tw|hk|mo)(-|$)/.test(lower)) return tag;
if (lower === 'zh' || lower.startsWith('zh-')) return 'zh-CN';
return tag;
}

/**
* Detect the user's preferred locale.
*
* Returns two values:
* - `locale`: the full BCP 47 tag (e.g. "en-GB") for formatting (dates, numbers).
* - `messageLocale`: the base language that has a translation catalog (e.g. "en").
* - `messageLocale`: the locale key that has a translation catalog (e.g. "en", "zh-CN").
*/
export function getLocale(): { locale: string; messageLocale: string } {
const explicit =
Expand All @@ -32,13 +47,22 @@ export function getLocale(): { locale: string; messageLocale: string } {
candidates.push(explicit);
}

if (typeof navigator !== 'undefined' && navigator.language) {
candidates.push(navigator.language);
// Walk navigator.languages (full preference list) so a user whose primary UI
// language isn't supported still gets a supported language from later in their list.
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
for (const tag of navigator.languages) {
if (tag) candidates.push(tag);
}
}
Comment on lines +52 to 56

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 Add navigator.language fallback in locale detection

The new getLocale path only reads navigator.languages, so when that array is unavailable or empty (common in jsdom tests and some embedded browser contexts), locale resolution skips the browser locale entirely and falls back to English unless GOOSE_LOCALE is set. This is a regression from the previous behavior that used navigator.language, and it can silently disable both regional formatting and message catalog selection for users whose locale is otherwise detectable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Electron ships Chromium, which has had navigator.languages since 2014. There's no Electron runtime where it's unavailable — the only place that case can occur is in jsdom tests, which we control directly.


for (const tag of candidates) {
for (const rawTag of candidates) {
// Normalize underscores to hyphens so POSIX-style tags like "zh_CN" work.
const normalized = rawTag.replace(/_/g, '-');
const tag = resolveChineseAlias(normalized);

// Exact match first
if (SUPPORTED_LOCALES.has(tag)) return { locale: tag, messageLocale: tag };
Comment on lines +61 to 64

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 Preserve regional locale when aliasing zh variants

getLocale currently rewrites any non-Traditional zh-* tag to zh-CN before both catalog selection and locale return, so users with locales like zh-SG or zh-MY lose their original regional locale for Intl formatting. This means date/number/currency formatting will follow mainland conventions even when the OS/browser region is Singapore or Malaysia. Keep the aliasing for messageLocale, but preserve the canonicalized original tag for locale when it is a valid BCP 47 locale.

Useful? React with 👍 / 👎.


// 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];
Expand All @@ -48,7 +72,7 @@ export function getLocale(): { locale: string; messageLocale: string } {
// Intl APIs, so fall back to the base language in that case.
let locale = base;
try {
[locale] = Intl.getCanonicalLocales(tag);
[locale] = Intl.getCanonicalLocales(normalized);
} catch {
// tag is not valid BCP 47 — use the base language instead
}
Expand All @@ -70,9 +94,7 @@ export const currentMessageLocale = resolvedLocale.messageLocale;
* Load compiled messages for a given locale.
* Returns an empty object for English (react-intl uses defaultMessage as fallback).
*/
export async function loadMessages(
locale: string
): Promise<Record<string, string>> {
export async function loadMessages(locale: string): Promise<Record<string, string>> {
if (locale === 'en') {
// English strings live in source code as defaultMessage — no catalog needed.
return {};
Expand All @@ -83,7 +105,9 @@ export async function loadMessages(
const mod = await import(`./compiled/${locale}.json`);
return mod.default ?? mod;
} catch {
console.warn(`[i18n] No message catalog found for locale "${locale}", falling back to English.`);
console.warn(
`[i18n] No message catalog found for locale "${locale}", falling back to English.`
);
return {};
}
}
6 changes: 6 additions & 0 deletions ui/desktop/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@
"chatInput.failedToReadImage": {
"defaultMessage": "Failed to read image file"
},
"chatInput.navigationShortcut": {
"defaultMessage": "{prefix}↑/{prefix}↓ to navigate messages"
},
"chatInput.processingDroppedFiles": {
"defaultMessage": "Processing dropped files..."
},
Expand Down Expand Up @@ -2390,6 +2393,9 @@
"navigationCustomization.itemSettings": {
"defaultMessage": "Settings"
},
"navigationCustomization.itemSkills": {
"defaultMessage": "Skills"
},
"navigationCustomization.resetToDefaults": {
"defaultMessage": "Reset to defaults"
},
Expand Down
Loading