diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index bb0563b4..06026304 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -627,9 +627,9 @@ describe('Content-only pages render without errors', () => { expect(sendMessage).not.toHaveBeenCalled() expect(screen.getByText('Slash commands')).toBeInTheDocument() - expect(screen.getByText('/clear')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() expect(screen.getByText('/cost')).toBeInTheDocument() - expect(screen.getByText('15 more commands available. Type / to search the full command list.')).toBeInTheDocument() + expect(screen.getByText('9 more commands available. Type / to search the full command list.')).toBeInTheDocument() resetPageStores() }) diff --git a/desktop/src/components/chat/ComposerReferenceMenu.test.tsx b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx index e616c968..bea32317 100644 --- a/desktop/src/components/chat/ComposerReferenceMenu.test.tsx +++ b/desktop/src/components/chat/ComposerReferenceMenu.test.tsx @@ -25,7 +25,7 @@ it('unifies plugin, skill and file groups with structured selection and active o const onActiveChange = vi.fn() render() await screen.findByRole('option', { name: 'app.ts' }) - expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['HyperFramesVideo creation', 'DesignCreate interfaces', 'src', 'app.ts']) + expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['HyperFramesVideo creationPlugin', 'DesignCreate interfacesPersonal', 'src', 'app.ts']) expect(onActiveChange).toHaveBeenLastCalledWith('references-option-0') act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) }) expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'plugin', id: 'hyperframes', modelText: 'Use HyperFrames', path: '', isDirectory: false })) @@ -81,6 +81,27 @@ it('keeps plugin matches usable when file loading fails without exposing raw err expect(onSelect).toHaveBeenCalled() }) +it('uses the shared fallback vocabulary and labels where each reference came from', async () => { + const { container } = render() + await screen.findByRole('option', { name: 'app.ts' }) + // The skill row must use the same outline box the slash menu uses; the + // decorative sparkle it used to render made the two menus disagree. + expect(container.querySelector('.lucide-box')).toBeInTheDocument() + expect(container.querySelector('.lucide-sparkles')).toBeNull() + expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveTextContent('Plugin') + expect(screen.getByRole('option', { name: 'Design' })).toHaveTextContent('Personal') + expect(screen.getByRole('option', { name: 'src' })).not.toHaveTextContent('Personal') +}) + +it('resolves brand icons against the packaged asset base instead of the document root', async () => { + vi.stubEnv('BASE_URL', './') + try { + render() + const option = await screen.findByRole('option', { name: 'HyperFrames' }) + expect(option.querySelector('img')).toHaveAttribute('src', './connectors/hyperframes.svg') + } finally { vi.unstubAllEnvs() } +}) + it('browses explicit path filters and reports no active descendant for empty results', async () => { vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work/src', parentPath: '/work', entries: [] }) const onActiveChange = vi.fn() diff --git a/desktop/src/components/chat/ComposerReferenceMenu.tsx b/desktop/src/components/chat/ComposerReferenceMenu.tsx index 09e95a4d..8a3bc7a5 100644 --- a/desktop/src/components/chat/ComposerReferenceMenu.tsx +++ b/desktop/src/components/chat/ComposerReferenceMenu.tsx @@ -1,13 +1,15 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -import { ChevronRight, File, Folder, Package, Sparkles } from 'lucide-react' +import { ChevronRight } from 'lucide-react' import { ApiError } from '@/api/client' import { filesystemApi } from '@/api/filesystem' import { useTranslation } from '@/i18n' import { safeMentionIcon, type NewComposerMention } from '@/lib/composerMentions' +import { publicAssetPath } from '@/lib/publicAsset' import type { ComposerReferenceCandidate } from '@/types/composerReference' +import { referenceFallbackIcon, skillSourceLabelKey } from './referencePresentation' type FileEntry = { name: string, path: string, isDirectory: boolean, relativePath?: string } -type Row = { key: string, label: string, description: string, mention: NewComposerMention, file?: FileEntry } +type Row = { key: string, label: string, description: string, source?: string, mention: NewComposerMention, file?: FileEntry } export type ComposerReferenceMenuHandle = { handleKeyDown(event: KeyboardEvent): void } type Props = { id: string @@ -62,7 +64,7 @@ export const ComposerReferenceMenu = forwardRef !query.endsWith('/') && query.split(/\s+/).every(word => `${item.displayName} ${item.name} ${item.description}`.toLocaleLowerCase().includes(word))) const referenceRow = (item: ComposerReferenceCandidate): Row => ({ - key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description, + key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description, source: item.source, mention: { kind: item.kind, id: item.id, label: item.displayName || item.name, path: item.path ?? '', isDirectory: false, description: item.description, icon: safeMentionIcon(item.icon), modelText: item.modelText }, }) const files: Row[] = (currentResult?.entries ?? []).map(entry => { @@ -120,16 +122,18 @@ export const ComposerReferenceMenu = forwardRef{group.label} {group.rows.map((row, position) => { const index = start + position - const Icon = row.file ? (row.file.isDirectory ? Folder : File) : row.mention.kind === 'plugin' ? Package : Sparkles + const Icon = referenceFallbackIcon(row.file ? (row.file.isDirectory ? 'directory' : 'file') : row.mention.kind ?? 'skill') + const sourceLabel = row.file ? null : skillSourceLabelKey(row.source) return
highlight(row)} onClick={event => { if ((event.target as Element).closest('[data-navigate-directory]')) navigate(row) else onSelect(row.mention) }} className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${activeIndex === index ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}> - {row.mention.icon ? :
})} diff --git a/desktop/src/components/chat/SlashCommandMenu.test.tsx b/desktop/src/components/chat/SlashCommandMenu.test.tsx index c0901d69..aac22285 100644 --- a/desktop/src/components/chat/SlashCommandMenu.test.tsx +++ b/desktop/src/components/chat/SlashCommandMenu.test.tsx @@ -59,6 +59,21 @@ it('uses safe skill branding and keeps the description beside its name without c expect(onSelect).toHaveBeenCalledWith('video') }) +it('resolves brand icons against the packaged asset base instead of the document root', () => { + vi.stubEnv('BASE_URL', './') + try { + render() + expect(screen.getByRole('option').querySelector('img')).toHaveAttribute('src', './connectors/hyperframes.svg') + } finally { vi.unstubAllEnvs() } +}) + +it('falls back to the shared icon vocabulary so skills match the mention menu', () => { + const { container } = render() + expect(container.querySelector('.lucide-package')).toBeInTheDocument() + expect(container.querySelector('.lucide-box')).toBeInTheDocument() + expect(screen.getByText('Personal')).toBeInTheDocument() +}) + it('uses the same command-plugin-skill order for option ids and keyboard references', () => { const onSelect = vi.fn() const itemRefs = { current: [] as (HTMLElement | null)[] } diff --git a/desktop/src/components/chat/SlashCommandMenu.tsx b/desktop/src/components/chat/SlashCommandMenu.tsx index c94a523a..cff9cc33 100644 --- a/desktop/src/components/chat/SlashCommandMenu.tsx +++ b/desktop/src/components/chat/SlashCommandMenu.tsx @@ -1,7 +1,6 @@ import { forwardRef, type MutableRefObject } from 'react' import { Bot, - Box, Bug, CircleDollarSign, CircleGauge, @@ -25,9 +24,10 @@ import { } from 'lucide-react' import { useTranslation } from '@/i18n' import type { SlashCommandGroups } from './composerUtils' -import type { SlashCommandSource } from '@/types/slashCommand' import type { ComposerReferenceCandidate } from '@/types/composerReference' import { safeMentionIcon } from '@/lib/composerMentions' +import { publicAssetPath } from '@/lib/publicAsset' +import { referenceFallbackIcon, skillSourceLabelKey } from './referencePresentation' const SYSTEM_SLASH_COMMAND_ICONS: Record = { agent: Bot, @@ -61,17 +61,6 @@ function getSystemSlashCommandIcon(commandName: string): LucideIcon { return SYSTEM_SLASH_COMMAND_ICONS[rootCommand] ?? CommandIcon } -function getSkillSourceLabelKey(source: SlashCommandSource) { - switch (source) { - case 'project': - return 'chat.slashSkillProject' as const - case 'plugin': - return 'chat.slashSkillPlugin' as const - case 'user': - return 'chat.slashSkillPersonal' as const - } -} - export function getSlashCommandOptionId(menuId: string, index: number): string { return `${menuId}-option-${index}` } @@ -167,17 +156,18 @@ export const SlashCommandMenu = forwardRef item.kind === group.kind && (item.name === command.name || item.id === command.name)) const icon = safeMentionIcon(candidate?.icon) - const Icon = group.kind === 'plugin' ? Package : Box + const Icon = referenceFallbackIcon(group.kind) + const sourceLabel = skillSourceLabelKey(command.source) return
{ itemRefs.current[index] = element }} onClick={() => onSelect(command.name)} onMouseEnter={() => onHighlight(index)} className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${index === selectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}> - {icon ? :
})} diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 13938989..16a43de7 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + FALLBACK_SLASH_COMMANDS, appendAgentSlashCommands, buildAgentSlashCommands, filterSlashCommands, @@ -41,7 +42,7 @@ describe('composerUtils', () => { }) }) - it('merges fallback commands so built-in entries like /clear remain visible', () => { + it('merges fallback commands so built-in entries like /compact remain visible', () => { expect( mergeSlashCommands([ { name: 'help', description: '' }, @@ -49,12 +50,22 @@ describe('composerUtils', () => { ).toEqual( expect.arrayContaining([ { name: 'help', description: 'Show available desktop and agent commands' }, - { name: 'clear', description: 'Clear conversation history' }, + { name: 'compact', description: 'Compact conversation context' }, { name: 'context', description: 'Show current context usage' }, ]), ) }) + it('never falls back to commands this desktop cannot run', () => { + // The headless CLI answers these with "Unknown skill", so offering them in + // the menu is a dead end. They regressing back in means the fallback list + // drifted away from what the session can actually execute. + const names = FALLBACK_SLASH_COMMANDS.map(command => command.name) + for (const dead of ['clear', 'vim', 'terminal-setup', 'permissions', 'commit', 'pr', 'bug', 'login', 'logout']) { + expect(names).not.toContain(dead) + } + }) + it('keeps server-provided descriptions for non-built-in commands', () => { expect( mergeSlashCommands([ @@ -164,6 +175,38 @@ describe('composerUtils', () => { ]) }) + it('opens on the commands the desktop owns instead of the CLI registration order', () => { + // The CLI lists its bundled skills first, so an unprioritised menu opens on + // `update-config` / `debug` / `batch`. Desktop-owned commands lead instead, + // and everything else keeps the order its source gave it. + const commands = [ + { name: 'update-config', description: 'Configure' }, + { name: 'debug', description: 'Debug' }, + { name: 'compact', description: 'Compact conversation context' }, + { name: 'help', description: 'Show available commands' }, + { name: 'model', description: 'Switch AI model' }, + ] + expect(filterSlashCommands(commands, '').map((command) => command.name)).toEqual([ + 'help', + 'model', + 'update-config', + 'debug', + 'compact', + ]) + }) + + it('leaves match ranking alone once a query is typed', () => { + const commands = [ + { name: 'help', description: 'Show available commands' }, + { name: 'compact', description: 'Compact conversation context' }, + { name: 'update-config', description: 'Compact the config' }, + ] + expect(filterSlashCommands(commands, 'comp').map((command) => command.name)).toEqual([ + 'compact', + 'update-config', + ]) + }) + it('groups built-in app commands before personal skills without changing their relative order', () => { const groups = groupSlashCommands([ { name: 'amazon-review-scraper', description: 'Collect Amazon reviews', kind: 'skill', source: 'user' }, @@ -247,9 +290,9 @@ describe('composerUtils', () => { 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') + const contextCmd = commands.find((c) => c.name === 'context') + expect(contextCmd?.description).toBe('Show current context usage') + expect(contextCmd?.description).not.toBe('slashCmd.context.description') // Verify every command renders a human-readable description, never a raw key for (const cmd of commands) { @@ -260,14 +303,14 @@ describe('composerUtils', () => { it('uses the localized description when the translation key resolves to a real string', () => { const mockT = (key: string) => { const map: Record = { - 'slashCmd.clear.description': '清空会话历史', + 'slashCmd.context.description': '当前上下文用量', } return map[key] ?? key } const commands = getLocalizedFallbackCommands(mockT) - const clearCmd = commands.find((c) => c.name === 'clear') - expect(clearCmd?.description).toBe('清空会话历史') + const contextCmd = commands.find((c) => c.name === 'context') + expect(contextCmd?.description).toBe('当前上下文用量') // A command without a translated key should still fall back to English const mcpCmd = commands.find((c) => c.name === 'mcp') diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index a04035e2..17206676 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -63,15 +63,47 @@ export const SLASH_COMMAND_ALIASES = [ { name: 'settings', target: 'config' }, ] as const -const DESKTOP_RESERVED_SLASH_COMMAND_NAMES = new Set( - [ - ...PANEL_SLASH_COMMANDS.map(command => command.name), - ...SETTINGS_SLASH_COMMANDS.map(command => command.name), - ...SLASH_COMMAND_ALIASES.map(command => command.name), - 'model', - ].map(name => name.toLowerCase()), +/** + * Commands the desktop owns, in the order the slash menu should lead with them. + * The order is the one the panel and settings tables declare, so the first + * screen stays the same no matter how the CLI happened to register its list. + */ +const DESKTOP_SLASH_COMMAND_NAMES: readonly string[] = [ + ...PANEL_SLASH_COMMANDS.map(command => command.name), + ...SETTINGS_SLASH_COMMANDS.map(command => command.name), + ...SLASH_COMMAND_ALIASES.map(command => command.name), + 'model', +] + +/** + * A session's command list is stitched together from the CLI's own registration + * (its bundled skills first) and the desktop fallback, which leaves entries such + * as `update-config`, `debug` and `batch` above the fold while the commands a + * user reaches for sit below it. Desktop-owned commands are unconditional — the + * client runs them itself — so they lead, and everything else keeps the order + * its source gave it. + */ +const PREFERRED_SLASH_COMMAND_RANKS = new Map( + DESKTOP_SLASH_COMMAND_NAMES.map((name, index) => [name.toLowerCase(), index] as const), ) +function prioritizeSlashCommands(commands: SlashCommandOption[]): SlashCommandOption[] { + const rankOf = (command: SlashCommandOption): number | undefined => + PREFERRED_SLASH_COMMAND_RANKS.get(command.name.trim().toLowerCase()) + const preferred = commands + .map((command, index) => ({ command, index, rank: rankOf(command) })) + .filter((entry): entry is { command: SlashCommandOption, index: number, rank: number } => entry.rank !== undefined) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + if (!preferred.length || preferred.length === commands.length) return commands + return [ + ...preferred.map(entry => entry.command), + ...commands.filter(command => rankOf(command) === undefined), + ] +} + +/** Commands the desktop reserves for itself; new workflows must not claim them. */ +const DESKTOP_RESERVED_SLASH_COMMAND_NAMES = new Set(DESKTOP_SLASH_COMMAND_NAMES.map(name => name.toLowerCase())) + export type SlashCommandNameConflict = 'reserved' | 'existing' /** A saved workflow must not claim a slash command that the desktop already owns. */ @@ -87,7 +119,17 @@ export function getSlashCommandNameConflict( : null } -/** Static fallback with English descriptions (for non-React contexts) */ +/** + * Static fallback with English descriptions (for non-React contexts). + * + * Only commands this desktop can actually run belong here. The list is merged + * into the slash menu for sessions whose CLI has not reported its own commands + * yet, and every entry either resolves locally (see `resolveSlashUiAction`) or + * runs in the headless CLI. Interactive-TUI-only commands (`clear`, `vim`, + * `terminal-setup`, `permissions`), internal commands (`commit`, `pr`, `bug`) + * and sign-in commands are deliberately absent: the headless CLI answers them + * with "Unknown skill" instead of doing anything. + */ export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [ { name: 'agent', description: 'Run a prompt with a selected Agent', argumentHint: ' ' }, { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, @@ -101,20 +143,11 @@ export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [ { name: 'memory', description: 'Open project memory files in Settings' }, { name: 'doctor', description: 'Open Doctor in Diagnostics' }, { name: 'compact', description: 'Compact conversation context' }, - { name: 'clear', description: 'Clear conversation history' }, { name: 'goal', description: 'Set a completion goal', argumentHint: '[ | clear]' }, { name: 'review', description: 'Review code changes' }, - { name: 'commit', description: 'Create a git commit' }, - { name: 'pr', description: 'Create a pull request' }, { name: 'init', description: 'Initialize project CLAUDE.md' }, - { name: 'bug', description: 'Report a bug' }, { name: 'config', description: 'Open configuration' }, - { name: 'login', description: 'Switch Anthropic accounts' }, - { name: 'logout', description: 'Sign out of current account' }, { name: 'model', description: 'Switch AI model' }, - { name: 'permissions', description: 'View or manage tool permissions' }, - { name: 'terminal-setup', description: 'Set up terminal integration' }, - { name: 'vim', description: 'Toggle vim editing mode' }, ] /** Build localized fallback commands using the current locale. @@ -322,7 +355,9 @@ export function filterSlashCommands( filter: string, ): SlashCommandOption[] { const normalized = filter.toLowerCase() - if (!normalized.trim()) return [...commands] + // No query yet: this is the order the menu opens on, so lead with the + // commands the desktop owns instead of whatever the CLI registered first. + if (!normalized.trim()) return prioritizeSlashCommands([...commands]) return commands .map((command, index) => ({ diff --git a/desktop/src/components/chat/referencePresentation.ts b/desktop/src/components/chat/referencePresentation.ts new file mode 100644 index 00000000..73c75123 --- /dev/null +++ b/desktop/src/components/chat/referencePresentation.ts @@ -0,0 +1,31 @@ +import { Box, File, Folder, Package, type LucideIcon } from 'lucide-react' +import type { TranslationKey } from '@/i18n' + +/** + * Icon shown for a skill, plugin or filesystem entry when no brand asset exists. + * + * The `@` and `/` menus render the same vocabulary on purpose: the same entry + * must not change shape depending on which menu opened it. Skills use the same + * outline box the Codex composer uses for `SKILL.md`, plugins use the package + * glyph, and filesystem rows fall back to folder/file. + */ +export function referenceFallbackIcon(kind: 'skill' | 'plugin' | 'file' | 'directory'): LucideIcon { + if (kind === 'plugin') return Package + if (kind === 'skill') return Box + if (kind === 'directory') return Folder + return File +} + +/** + * i18n key describing where a skill came from, or null when the source is not + * one we label. New sources must degrade to no label rather than an empty + * string, so callers render nothing instead of a blank chip. + */ +export function skillSourceLabelKey(source: string | undefined): TranslationKey | null { + switch (source) { + case 'project': return 'chat.slashSkillProject' + case 'plugin': return 'chat.slashSkillPlugin' + case 'user': return 'chat.slashSkillPersonal' + default: return null + } +} diff --git a/desktop/src/components/connectors/ConnectorRow.tsx b/desktop/src/components/connectors/ConnectorRow.tsx index 4c492a23..fa33bc40 100644 --- a/desktop/src/components/connectors/ConnectorRow.tsx +++ b/desktop/src/components/connectors/ConnectorRow.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from 'react' import { IconButton } from '@/components/ui/IconButton' -export function ConnectorRow({ id, name, description, kind, status, actionLabel, added, disabled, onDetails, onAction, action }: { +export function ConnectorRow({ id, name, description, kind, status, actionLabel, added, onDetails, onAction, action }: { action?: ReactNode id: string name: string @@ -10,7 +10,6 @@ export function ConnectorRow({ id, name, description, kind, status, actionLabel, status?: string actionLabel: string added: boolean - disabled?: boolean onDetails: () => void onAction: () => void }) { @@ -26,6 +25,6 @@ export function ConnectorRow({ id, name, description, kind, status, actionLabel, {status && {status}} - {action ??