diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 1c0df7a9d3..6cf2b93481 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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( () => ({ diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 6354ac40bc..c10709d0d4 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -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', () => { diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index bc2b8899e7..b47ee7ed95 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -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) => ({ diff --git a/packages/web-shell/client/constants/localCommands.test.ts b/packages/web-shell/client/constants/localCommands.test.ts index 925cb72d2a..40d2a75ddb 100644 --- a/packages/web-shell/client/constants/localCommands.test.ts +++ b/packages/web-shell/client/constants/localCommands.test.ts @@ -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[] = [ { diff --git a/packages/web-shell/client/constants/localCommands.ts b/packages/web-shell/client/constants/localCommands.ts index 4ba300ca8d..00516e672b 100644 --- a/packages/web-shell/client/constants/localCommands.ts +++ b/packages/web-shell/client/constants/localCommands.ts @@ -181,6 +181,7 @@ const SKILL_DESCRIPTION_KEYS: Record = { // 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) }; }); } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index d0a6efc4ef..68bb6e6d50 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -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',