diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index a9a91011..32c3a057 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { filterSlashCommands, findSlashToken, + getLocalizedFallbackCommands, insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, @@ -143,4 +144,38 @@ describe('composerUtils', () => { expect(resolveSlashUiAction('context')).toEqual({ type: 'panel', command: 'context' }) expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' }) }) + + it('falls back to the static English description when a translation key is missing', () => { + // Simulate an i18n t() function that returns the raw key for missing entries + // (this is what the real translate() does via zh[key] ?? en[key] ?? key). + const mockT = (key: string) => key + + const commands = getLocalizedFallbackCommands(mockT) + const clearCmd = commands.find((c) => c.name === 'clear') + expect(clearCmd?.description).toBe('Clear conversation history') + expect(clearCmd?.description).not.toBe('slashCmd.clear.description') + + // Verify every command renders a human-readable description, never a raw key + for (const cmd of commands) { + expect(cmd.description).not.toMatch(/^slashCmd\./) + } + }) + + it('uses the localized description when the translation key resolves to a real string', () => { + const mockT = (key: string) => { + const map: Record = { + 'slashCmd.clear.description': '清空会话历史', + } + return map[key] ?? key + } + + const commands = getLocalizedFallbackCommands(mockT) + const clearCmd = commands.find((c) => c.name === 'clear') + expect(clearCmd?.description).toBe('清空会话历史') + + // A command without a translated key should still fall back to English + const mcpCmd = commands.find((c) => c.name === 'mcp') + expect(mcpCmd?.description).toBe('Open available MCP tools for the current chat context') + expect(mcpCmd?.description).not.toBe('slashCmd.mcp.description') + }) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index ccf967ae..517474cc 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -79,13 +79,34 @@ export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [ { name: 'vim', description: 'Toggle vim editing mode' }, ] -/** Build localized fallback commands using the current locale */ +/** Build localized fallback commands using the current locale. + * + * Resolution order for each command's description: + * 1. Localized string from the i18n table (zh -> en) when a key is registered. + * 2. The static English description shipped in FALLBACK_SLASH_COMMANDS. + * + * This guarantees we never render a raw key (e.g. "slashCmd.foo.description") + * in the UI even if a command is missing from SLASH_CMD_DESCRIPTION_KEYS or + * its translation entry is absent. + */ export function getLocalizedFallbackCommands(t: (key: TranslationKey) => string): SlashCommandOption[] { - return FALLBACK_SLASH_COMMANDS.map((cmd) => ({ - name: cmd.name, - description: t(SLASH_CMD_DESCRIPTION_KEYS[cmd.name] ?? ('slashCmd.' + cmd.name + '.description' as TranslationKey)), - ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), - })) + return FALLBACK_SLASH_COMMANDS.map((cmd) => { + const key = SLASH_CMD_DESCRIPTION_KEYS[cmd.name] + let description = cmd.description + if (key) { + const translated = t(key) + // i18n returns the key itself when no translation is found; fall back to + // the static English description in that case. + if (translated && translated !== key) { + description = translated + } + } + return { + name: cmd.name, + description, + ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), + } + }) } export type SlashCommandOption = {