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
16 changes: 13 additions & 3 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog';
import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog';
import { RewindDialog } from './components/dialogs/RewindDialog';
import { WebShellSidebar } from './components/sidebar/WebShellSidebar';
import { getLocalCommands } from './constants/localCommands';
import {
getLocalCommands,
localizeBuiltinDescriptions,
skillDescriptionKey,
} from './constants/localCommands';
import { mergeCommands } from './hooks/daemonSessionMappers';
import { useAnimationFrameValue } from './hooks/useAnimationFrameValue';
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
Expand Down Expand Up @@ -3380,16 +3384,22 @@ export function App({

const commands = useMemo(() => {
const skillNames = new Set(connection.skills ?? []);
return mergeCommands(connection.commands ?? [], getLocalCommands(t))
return localizeBuiltinDescriptions(
mergeCommands(connection.commands ?? [], getLocalCommands(t)),
t,
)
.filter(
(command) => !hiddenCommands.has(normalizeHiddenCommand(command.name)),
)
.map((command) => {
if (!skillNames.has(command.name)) return command;
const skillKey = skillDescriptionKey(command.name);
return {
...command,
displayCategory: 'skill' as const,
description: command.description || t('skills.run'),
description: skillKey
? t(skillKey)
: command.description || t('skills.run'),
};
});
}, [connection.commands, connection.skills, hiddenCommands, t]);
Expand Down
161 changes: 161 additions & 0 deletions packages/web-shell/client/constants/localCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import type { CommandInfo } from '../adapters/types';
import { getTranslator } from '../i18n';
import { mergeCommands } from '../hooks/daemonSessionMappers';
import {
getLocalCommands,
localizeBuiltinDescriptions,
skillDescriptionKey,
} from './localCommands';

const zh = getTranslator('zh-CN');
const en = getTranslator('en');

describe('getLocalCommands', () => {
it('translates fallback command descriptions to the active language', () => {
const byName = new Map(getLocalCommands(zh).map((c) => [c.name, c]));
expect(byName.get('status')?.description).toBe('查看版本信息');
expect(byName.get('help')?.description).toBe('查看帮助和可用命令');
expect(
getLocalCommands(en).every((c) => c.source === 'builtin-command'),
).toBe(true);
});
});

describe('localizeBuiltinDescriptions (commands)', () => {
it('re-localizes a built-in the daemon advertised in another language', () => {
const commands: CommandInfo[] = [
{
name: 'bug',
description: 'submit a bug report',
source: 'builtin-command',
},
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'提交错误报告',
);
expect(localizeBuiltinDescriptions(commands, en)[0].description).toBe(
'Submit a bug report',
);
});

it('leaves a custom command that shadows a built-in name untouched', () => {
const commands: CommandInfo[] = [
{ name: 'export', description: 'my project exporter' },
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'my project exporter',
);
});

it('does not touch built-ins that are not in the map', () => {
const commands: CommandInfo[] = [
{
name: 'clear',
description: 'Clear the screen',
source: 'builtin-command',
},
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'Clear the screen',
);
});

it('does not localize skills (that happens in the skill-tagging step)', () => {
const commands: CommandInfo[] = [
{
name: 'dataviz',
description: 'Design guidance for charts…',
source: 'bundled-skill',
},
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'Design guidance for charts…',
);
});

it('preserves the other command fields while replacing the description', () => {
const commands: CommandInfo[] = [
{
name: 'lsp',
description: 'Show LSP server status. Usage: /lsp [status]',
source: 'builtin-command',
argumentHint: '[status]',
},
];
const [out] = localizeBuiltinDescriptions(commands, zh);
expect(out.description).toBe('显示 LSP 服务器状态');
expect(out.argumentHint).toBe('[status]');
expect(out.name).toBe('lsp');
});
});

describe('skillDescriptionKey', () => {
it('maps bundled and project skills to i18n keys', () => {
expect(skillDescriptionKey('dataviz')).toBe('skilldesc.dataviz');
expect(skillDescriptionKey('bugfix')).toBe('skilldesc.bugfix');
expect(zh(skillDescriptionKey('dataviz')!)).toBe(
'图表与数据可视化设计指南',
);
expect(zh(skillDescriptionKey('bugfix')!)).toBe(
'按先复现流程修复 GitHub issue 中的 bug',
);
});

it('returns undefined for an unknown (user) skill', () => {
expect(skillDescriptionKey('my-personal-skill')).toBeUndefined();
});
});

describe('App command pipeline', () => {
// Mirrors App.tsx: merge -> localize commands -> tag skills (which localizes
// known skills by name, session-independently).
function pipeline(daemon: CommandInfo[], skills: string[]) {
const skillNames = new Set(skills);
return localizeBuiltinDescriptions(
mergeCommands(daemon, getLocalCommands(zh)),
zh,
).map((command) => {
if (!skillNames.has(command.name)) return command;
const key = skillDescriptionKey(command.name);
return {
...command,
description: key ? zh(key) : command.description || 'run',
};
});
}

it('localizes commands and both bundled + project skills, no session needed', () => {
const daemon: CommandInfo[] = [
{
name: 'bug',
description: 'submit a bug report',
source: 'builtin-command',
},
{
name: 'status',
description: 'show version info',
source: 'builtin-command',
},
// Skills advertised with NO source (welcome screen, pre-session).
{ name: 'dataviz', description: 'Design guidance for charts…' },
{ name: 'bugfix', description: 'Fix a bug from a GitHub issue…' },
// An unknown user skill keeps its authored description.
{ name: 'my-skill', description: 'my custom skill' },
// A plain custom command.
{ name: 'deploy', description: 'ship it', source: 'project-command' },
];
const byName = new Map(
pipeline(daemon, ['dataviz', 'bugfix', 'my-skill']).map((c) => [
c.name,
c.description,
]),
);
expect(byName.get('bug')).toBe('提交错误报告');
expect(byName.get('status')).toBe('查看版本信息');
expect(byName.get('dataviz')).toBe('图表与数据可视化设计指南'); // bundled skill
expect(byName.get('bugfix')).toBe('按先复现流程修复 GitHub issue 中的 bug'); // project skill
expect(byName.get('my-skill')).toBe('my custom skill'); // unknown skill untouched
expect(byName.get('deploy')).toBe('ship it'); // custom command untouched
});

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.

[Suggestion] Consider adding a test that validates every i18n key in BUILTIN_COMMAND_DESCRIPTION_KEYS and SKILL_DESCRIPTION_KEYS resolves to a real translation in both EN and ZH dictionaries. Currently, if a future contributor adds a map entry but forgets the matching local.* or skilldesc.* key in i18n.tsx, the getTranslator fallback chain (messages[key] ?? EN[key] ?? key) silently shows the raw key string (e.g. "skilldesc.newSkill") in the slash menu — a user-visible regression with no error signal.

A simple test would catch this:

import { BUILTIN_COMMAND_DESCRIPTION_KEYS, SKILL_DESCRIPTION_KEYS } from './localCommands';

const allKeys = [
  ...Object.values(BUILTIN_COMMAND_DESCRIPTION_KEYS),
  ...Object.values(SKILL_DESCRIPTION_KEYS),
];

it('every i18n key resolves in both EN and ZH', () => {
  for (const key of allKeys) {
    expect(en(key), `EN missing: ${key}`).not.toBe(key);
    expect(zh(key), `ZH missing: ${key}`).not.toBe(key);
  }
});

This makes the three-way sync (map → EN → ZH) self-enforcing via CI.

— qwen3.7-max via Qwen Code /review

});
102 changes: 102 additions & 0 deletions packages/web-shell/client/constants/localCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,105 @@ export function getLocalCommands(t: Translate): CommandInfo[] {
source: 'builtin-command',
}));
}

/**
* Built-in commands the daemon advertises but that are NOT part of
* getLocalCommands (they are feature/extension-gated, so we must not pin them
* into the always-on fallback list). The daemon fills their descriptions in the
* daemon *process* language, which is independent of the web-shell UI language,
* so without this the slash menu ends up a mix of languages. We re-localize
* these built-ins by name to the current UI language.
*
* Maps command name -> i18n key. Guarded by source === 'builtin-command', so a
* user's custom command that happens to share a built-in name keeps its own
* description.
*/
const BUILTIN_COMMAND_DESCRIPTION_KEYS: Record<string, string> = {
bug: 'local.bug',
compress: 'local.compress',
'compress-fast': 'local.compressFast',
config: 'local.config',
diff: 'local.diff',
directory: 'local.directory',
docs: 'local.docs',
doctor: 'local.doctor',
dream: 'local.dream',
effort: 'local.effort',
export: 'local.export',
forget: 'local.forget',
hooks: 'local.hooks',
'import-config': 'local.importConfig',
init: 'local.init',
insight: 'local.insight',
lsp: 'local.lsp',
remember: 'local.remember',
summary: 'local.summary',
workflows: 'local.workflows',
};

/**
* Skills whose author-written descriptions ship in English — the Qwen Code
* bundled skills plus this repo's `.qwen/skills` project skills. We re-localize
* their menu descriptions by name so a zh-CN slash menu isn't a mix of languages.
* Keyed by skill name because a skill only carries a reliable `source` once a
* session exists; the skill-tagging step keys off `connection.skills` instead, so
* this works on the welcome screen too. Display-only — the model still receives
* the daemon's canonical (English) description. Skills not listed here (a user's
* own skills, newly added ones) fall back to their authored description.
*/
const SKILL_DESCRIPTION_KEYS: Record<string, string> = {
// Bundled with Qwen Code (packages/core/src/skills/bundled).
batch: 'skilldesc.batch',
dataviz: 'skilldesc.dataviz',
'extension-creator': 'skilldesc.extensionCreator',
loop: 'skilldesc.loop',
'new-app': 'skilldesc.newApp',
'qc-helper': 'skilldesc.qcHelper',
review: 'skilldesc.review',
simplify: 'skilldesc.simplify',
stuck: 'skilldesc.stuck',
// This repo's project skills (.qwen/skills).
'agent-reproduce-align': 'skilldesc.agentReproduceAlign',
'agent-reproduce-feature': 'skilldesc.agentReproduceFeature',
bugfix: 'skilldesc.bugfix',
codegraph: 'skilldesc.codegraph',
'create-issue': 'skilldesc.createIssue',
'desktop-pet': 'skilldesc.desktopPet',
'docs-audit-and-refresh': 'skilldesc.docsAuditAndRefresh',
'docs-update-from-diff': 'skilldesc.docsUpdateFromDiff',
'e2e-testing': 'skilldesc.e2eTesting',
'feat-dev': 'skilldesc.featDev',
'memory-leak-debug': 'skilldesc.memoryLeakDebug',
'openwork-desktop-sync': 'skilldesc.openworkDesktopSync',
'prepare-pr': 'skilldesc.preparePr',
'qwen-code-claw': 'skilldesc.qwenCodeClaw',
'structured-debugging': 'skilldesc.structuredDebugging',
'terminal-capture': 'skilldesc.terminalCapture',
'tmux-real-user-testing': 'skilldesc.tmuxRealUserTesting',
triage: 'skilldesc.triage',
};

/**
* i18n key for a known skill's localized menu description, or undefined for a
* skill we don't ship a translation for (leave its authored description).
*/
export function skillDescriptionKey(name: string): string | undefined {
return SKILL_DESCRIPTION_KEYS[name];
}

/**
* Re-localize built-in command descriptions by name so the slash menu matches
* the web-shell UI language even when the daemon advertises them in its own
* process language. Guarded by source === 'builtin-command' so custom commands
* keep their own description. (Skills are localized in the skill-tagging step.)
*/
export function localizeBuiltinDescriptions(
commands: CommandInfo[],
t: Translate,
): CommandInfo[] {
return commands.map((command) => {
if (command.source !== 'builtin-command') return command;
const key = BUILTIN_COMMAND_DESCRIPTION_KEYS[command.name];
return key ? { ...command, description: t(key) } : command;
});
}
Loading
Loading