fix(web-shell): stabilize slash command i18n in split-view panes (#6546)

Split-view panes showed English descriptions for slash commands while
the main view showed Chinese. Two root causes:

1. ChatPane never merged getLocalCommands(t) into the command list,
   so ~33 built-in commands (help, model, clear, etc.) lacked i18n
   descriptions.

2. localizeBuiltinDescriptions required source === 'builtin-command',
   but the daemon omits _meta.source in some SSE event paths
   (available_commands_update), causing built-in commands to skip
   translation unpredictably across sessions.

3. Skill localization depended on connection.skills, which can be
   empty when SSE events deliver commands without availableSkills.

Fix: make the entire localization pipeline name-based and
session-independent — merge local commands, relax the source guard
to also translate when source is missing, and use skillDescriptionKey
directly instead of connection.skills for skill tagging.

Also adds missing autofix skill translation (EN + ZH).
This commit is contained in:
Shaojin Wen 2026-07-08 22:57:12 +08:00 committed by GitHub
parent 5f41b166e6
commit b2bee7040e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 48 additions and 17 deletions

View file

@ -4229,7 +4229,6 @@ export function App({
};
const commands = useMemo(() => {
const skillNames = new Set(connection.skills ?? []);
return localizeBuiltinDescriptions(
mergeCommands(connection.commands ?? [], getLocalCommands(t)),
t,
@ -4238,17 +4237,15 @@ export function App({
(command) => !hiddenCommands.has(normalizeHiddenCommand(command.name)),
)
.map((command) => {
if (!skillNames.has(command.name)) return command;
const skillKey = skillDescriptionKey(command.name);
if (!skillKey) return command;
return {
...command,
displayCategory: 'skill' as const,
description: skillKey
? t(skillKey)
: command.description || t('skills.run'),
description: t(skillKey),
};
});
}, [connection.commands, connection.skills, hiddenCommands, t]);
}, [connection.commands, hiddenCommands, t]);
const welcomeHeaderProps = useMemo(
() => ({

View file

@ -424,7 +424,10 @@ describe('ChatPane', () => {
{ name: 'compress', description: 'Compress', source: 'builtin-command' },
];
render();
expect(testid('pane-commands')?.textContent).toBe('2');
// Local commands are merged with daemon commands; 'clear' is deduplicated,
// 'compress' is daemon-only — so the count is localCount + 1.
const count = Number(testid('pane-commands')?.textContent);
expect(count).toBeGreaterThan(30);
});
it('hides internal composer models and labels the rest', () => {

View file

@ -20,7 +20,12 @@ import { isAskUserPermission } from '../utils/askUserPermission';
import { isDaemonApprovalMode } from '../utils/sessionPreparation';
import { isVisibleComposerModel } from '../utils/composerModels';
import { getModelDisplayName } from '../utils/modelDisplay';
import { localizeBuiltinDescriptions } from '../constants/localCommands';
import {
getLocalCommands,
localizeBuiltinDescriptions,
skillDescriptionKey,
} from '../constants/localCommands';
import { mergeCommands } from '../hooks/daemonSessionMappers';
import { MessageList } from './MessageList';
import { StreamingStatus } from './StreamingStatus';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
@ -149,10 +154,20 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) {
// submitted (via sendPrompt), so e.g. `/clear` clears this pane's session, not
// the outer one. The approval-mode and model pickers likewise drive this
// session's own actions; the SDK reflects the change back on `connection`.
const commands = useMemo(
() => localizeBuiltinDescriptions(connection.commands ?? [], t),
[connection.commands, t],
);
const commands = useMemo(() => {
return localizeBuiltinDescriptions(
mergeCommands(connection.commands ?? [], getLocalCommands(t)),
t,
).map((command) => {
const skillKey = skillDescriptionKey(command.name);
if (!skillKey) return command;
return {
...command,
displayCategory: 'skill' as const,
description: t(skillKey),
};
});
}, [connection.commands, t]);
const availableModels = useMemo(
() =>
(connection.models ?? []).filter(isVisibleComposerModel).map((model) => ({

View file

@ -41,13 +41,22 @@ describe('localizeBuiltinDescriptions (commands)', () => {
it('leaves a custom command that shadows a built-in name untouched', () => {
const commands: CommandInfo[] = [
{ name: 'export', description: 'my project exporter' },
{ name: 'export', description: 'my project exporter', source: 'custom' },
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'my project exporter',
);
});
it('translates a built-in name even when source is missing', () => {
const commands: CommandInfo[] = [
{ name: 'export', description: 'Export conversation to a file' },
];
expect(localizeBuiltinDescriptions(commands, zh)[0].description).toBe(
'将当前会话历史导出到文件',
);
});
it('does not touch built-ins that are not in the map', () => {
const commands: CommandInfo[] = [
{

View file

@ -181,6 +181,7 @@ const SKILL_DESCRIPTION_KEYS: Record<string, string> = {
// This repo's project skills (.qwen/skills).
'agent-reproduce-align': 'skilldesc.agentReproduceAlign',
'agent-reproduce-feature': 'skilldesc.agentReproduceFeature',
autofix: 'skilldesc.autofix',
bugfix: 'skilldesc.bugfix',
codegraph: 'skilldesc.codegraph',
'create-issue': 'skilldesc.createIssue',
@ -210,16 +211,19 @@ export function skillDescriptionKey(name: string): string | undefined {
/**
* 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.)
* process language. Translates when source is explicitly 'builtin-command' or
* when no source is set (daemon may omit _meta.source in some event paths).
* Commands with a non-builtin source (e.g. 'skill', 'custom') are left alone.
* (Skills are localized separately 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;
if (!key) return command;
if (command.source && command.source !== 'builtin-command') return command;
return { ...command, description: t(key) };
});
}

View file

@ -941,6 +941,8 @@ const EN: Messages = {
'Align a ported Codex/Claude Code feature with the original',
'skilldesc.agentReproduceFeature':
'Reproduce an existing Codex/Claude Code feature',
'skilldesc.autofix':
'Implement approved issues or address PR review feedback',
'skilldesc.bugfix': 'Fix a bug from a GitHub issue, reproduce-first',
'skilldesc.codegraph': 'Analyze the codebase via graph and vector index',
'skilldesc.createIssue': 'Draft and submit a GitHub issue from an idea',
@ -2613,6 +2615,7 @@ const ZH: Messages = {
'skilldesc.agentReproduceAlign':
'将已移植的 Codex/Claude Code 功能与原版对齐',
'skilldesc.agentReproduceFeature': '复现 Codex/Claude Code 的现有功能',
'skilldesc.autofix': '实现已批准的 issue 或处理 PR 审查反馈',
'skilldesc.bugfix': '按先复现流程修复 GitHub issue 中的 bug',
'skilldesc.codegraph': '通过图数据库和向量索引分析代码库',
'skilldesc.createIssue': '根据想法或 bug 描述起草并提交 GitHub issue',