From 812dc6957e19ac2d220e94ed2b3e452afcc1c6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Fri, 5 Jun 2026 22:12:31 -0500 Subject: [PATCH] Add searchable language picker --- .../src/components/language-switcher.test.tsx | 14 ++ .../src/components/language-switcher.tsx | 128 +++++++++++------- apps/desktop/src/i18n/en.ts | 4 +- apps/desktop/src/i18n/ja.ts | 4 +- apps/desktop/src/i18n/languages.ts | 19 ++- apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 4 +- apps/desktop/src/i18n/zh.ts | 4 +- 8 files changed, 119 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/components/language-switcher.test.tsx b/apps/desktop/src/components/language-switcher.test.tsx index 77614af22e5..3792012171a 100644 --- a/apps/desktop/src/components/language-switcher.test.tsx +++ b/apps/desktop/src/components/language-switcher.test.tsx @@ -6,6 +6,19 @@ import { type I18nConfigClient, I18nProvider } from '@/i18n' import { LanguageSwitcher } from './language-switcher' +// cmdk (the searchable list) wires a ResizeObserver and scrolls the active +// item into view — neither exists in jsdom. Stub them, matching the polyfill +// idiom in tool-approval-group.test.tsx. +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) + +Element.prototype.scrollIntoView = function scrollIntoView() {} + describe('LanguageSwitcher', () => { afterEach(() => { cleanup() @@ -15,6 +28,7 @@ describe('LanguageSwitcher', () => { it('persists language changes through display.language config', async () => { const saveConfig = vi.fn().mockResolvedValue({ ok: true }) const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } } + const configClient: I18nConfigClient = { getConfig: vi.fn().mockResolvedValue(latestConfig), saveConfig diff --git a/apps/desktop/src/components/language-switcher.tsx b/apps/desktop/src/components/language-switcher.tsx index 59edb622ef1..a95c361d485 100644 --- a/apps/desktop/src/components/language-switcher.tsx +++ b/apps/desktop/src/components/language-switcher.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' +import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { ScrollArea } from '@/components/ui/scroll-area' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet' import { useIsMobile } from '@/hooks/use-mobile' import { type Locale, LOCALE_META, useI18n } from '@/i18n' @@ -17,12 +17,14 @@ export interface LanguageSwitcherProps { dropUp?: boolean } -interface LanguageSwitcherOptionsProps { +interface LanguageCommandProps { allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]> + autoFocus?: boolean disabled?: boolean - label: string locale: Locale + noResults: string onSelect: (code: Locale) => void + searchPlaceholder: string } export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) { @@ -37,6 +39,7 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false const selectLocale = async (code: Locale) => { if (code === locale || isSavingLocale) { setOpen(false) + return } @@ -53,8 +56,8 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false const trigger = ( @@ -83,15 +86,14 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false {title} {t.language.description} - - void selectLocale(code)} - /> - + void selectLocale(code)} + searchPlaceholder={t.language.searchPlaceholder} + /> ) @@ -100,46 +102,74 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false return ( {trigger} - - - void selectLocale(code)} - /> - + + void selectLocale(code)} + searchPlaceholder={t.language.searchPlaceholder} + /> ) } -function LanguageSwitcherOptions({ allLocales, disabled, label, locale, onSelect }: LanguageSwitcherOptionsProps) { - return ( -
- {allLocales.map(([code, meta]) => { - const selected = code === locale +function LanguageCommand({ + allLocales, + autoFocus, + disabled, + locale, + noResults, + onSelect, + searchPlaceholder +}: LanguageCommandProps) { + const [search, setSearch] = useState('') - return ( - - ) - })} -
+ // Own the search term and filter manually. cmdk's built-in shouldFilter + // reorders items by its fuzzy-match score (≈alphabetical with an empty + // query), which destroys the curated en→zh→zh-hant→ja order. We disable it + // and do a plain substring filter that preserves array order — matching + // model-picker.tsx. Match against the endonym, the (hidden) English name, + // and the locale code so "日本"/"japanese"/"ja" all find Japanese. + const q = search.trim().toLowerCase() + + const filtered = allLocales.filter( + ([code, meta]) => + !q || + meta.name.toLowerCase().includes(q) || + meta.englishName.toLowerCase().includes(q) || + code.toLowerCase().includes(q) + ) + + return ( + + + + {filtered.length === 0 ? ( +
{noResults}
+ ) : ( + filtered.map(([code, meta]) => { + const selected = code === locale + + return ( + onSelect(code)} + value={code} + > + + {meta.name} + {code} + + ) + }) + )} +
+
) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 17c42e2d365..8f99e8cdac5 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -132,7 +132,9 @@ export const en: Translations = { description: 'Choose the language for the desktop interface.', saving: 'Saving language…', saveError: 'Language update failed', - switchTo: 'Switch language' + switchTo: 'Switch language', + searchPlaceholder: 'Search languages…', + noResults: 'No languages found' }, settings: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3c93dd39555..93177c380cf 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -21,7 +21,9 @@ export const ja = defineLocale({ description: 'デスクトップインターフェイスの言語を選択します。', saving: '言語を保存中…', saveError: '言語の更新に失敗しました', - switchTo: '言語を切り替え' + switchTo: '言語を切り替え', + searchPlaceholder: '言語を検索…', + noResults: '言語が見つかりません' }, settings: { diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts index 2694b3ba5c3..5b4990f4970 100644 --- a/apps/desktop/src/i18n/languages.ts +++ b/apps/desktop/src/i18n/languages.ts @@ -6,31 +6,36 @@ export const LOCALE_OPTIONS = [ { id: 'en', name: 'English', + englishName: 'English', configValue: 'en' }, { id: 'zh', name: '简体中文', + englishName: 'Simplified Chinese', configValue: 'zh' }, { id: 'zh-hant', name: '繁體中文', + englishName: 'Traditional Chinese', configValue: 'zh-hant' }, { id: 'ja', name: '日本語', + englishName: 'Japanese', configValue: 'ja' } -] as const satisfies readonly { configValue: string; id: Locale; name: string }[] +] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[] -// Endonyms (native names) for the language picker so users recognize their -// language regardless of the current UI language. No country flags: -// languages are not countries. -export const LOCALE_META: Record = Object.fromEntries( - LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }]) -) as Record +// `name` is the endonym (native name) shown in the picker so users recognize +// their language regardless of the current UI language. No country flags: +// languages are not countries. `englishName` is search-only (not shown) so an +// English speaker can type "japanese"/"traditional" to filter the list. +export const LOCALE_META: Record = Object.fromEntries( + LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name, englishName: locale.englishName }]) +) as Record const LOCALE_ALIASES: Record = { en: 'en', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 6e7da1cbddc..7e794fc900d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -144,6 +144,8 @@ export interface Translations { saving: string saveError: string switchTo: string + searchPlaceholder: string + noResults: string } settings: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index fbfc0124312..d6d1b8c8b12 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -21,7 +21,9 @@ export const zhHant = defineLocale({ description: '選擇桌面介面的語言。', saving: '正在儲存語言…', saveError: '語言更新失敗', - switchTo: '切換語言' + switchTo: '切換語言', + searchPlaceholder: '搜尋語言…', + noResults: '找不到語言' }, settings: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3cdc6a01870..f9546491c1e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -128,7 +128,9 @@ export const zh: Translations = { description: '选择桌面界面的语言。', saving: '正在保存语言…', saveError: '语言更新失败', - switchTo: '切换语言' + switchTo: '切换语言', + searchPlaceholder: '搜索语言…', + noResults: '未找到语言' }, settings: {