mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
feat(desktop): align composer reference icons and drop dead slash commands
The @ and / menus each carried their own icon fallback — skills rendered as a sparkle in one and a box in the other — so the same entry changed shape depending on which menu opened it. Both now share one vocabulary, and the @ menu shows the source labels the slash menu already had. Brand icons were resolved against the document root, which only worked while `base` was `/`; the packaged renderer loads from file://, where `/connectors/x.svg` points outside the bundle. Connector rows also opened the detail view and started installing in the same click — installation now happens only from the detail action. The fallback command list no longer offers commands the headless CLI cannot run (`clear`, `vim`, `commit`, `pr`, …): selecting one only produced "Unknown skill". Of the 59 compiled commands, 16 support the headless path a desktop session drives. An unprioritised menu also opened on the CLI's bundled skills (`update-config`, `debug`, `batch`), so desktop-owned commands now lead instead.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ it('unifies plugin, skill and file groups with structured selection and active o
|
||||
const onActiveChange = vi.fn()
|
||||
render(<ComposerReferenceMenu ref={ref} id="references" cwd="/work" references={references} onSelect={onSelect} onActiveChange={onActiveChange} />)
|
||||
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(<ComposerReferenceMenu id="shared" cwd="/work" references={references} onSelect={vi.fn()} />)
|
||||
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(<ComposerReferenceMenu id="brand" cwd="/work" references={references} onSelect={vi.fn()} />)
|
||||
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()
|
||||
|
||||
@@ -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<ComposerReferenceMenuHandle, Pro
|
||||
const query = filter.trim().toLocaleLowerCase()
|
||||
const matches = references.filter(item => !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<ComposerReferenceMenuHandle, Pro
|
||||
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div>
|
||||
{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 <div key={row.key} id={getComposerReferenceOptionId(id, index)} role="option" tabIndex={-1} aria-selected={activeIndex === index} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
|
||||
onMouseEnter={() => 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 ? <img src={row.mention.icon} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />}
|
||||
{row.mention.icon ? <img src={publicAssetPath(row.mention.icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />}
|
||||
<span id={`${id}-label-${index}`} className="max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{row.label}</span>
|
||||
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{row.description}</span>
|
||||
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
|
||||
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-2 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center"><ChevronRight aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" /></span> : null}
|
||||
</div>
|
||||
})}
|
||||
|
||||
@@ -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(<SlashCommandMenu id="brand-base" groups={{ system: [], skills: [{ name: 'video', description: 'Make a video', kind: 'skill' }], ordered: [] }} selectedIndex={0} itemRefs={{ current: [] }} onSelect={vi.fn()} onHighlight={vi.fn()} showKeyboardHints={false} references={[{ kind: 'skill', id: 'video', name: 'video', displayName: 'Video', description: 'Make a video', source: 'plugin', modelText: '/video', icon: '/connectors/hyperframes.svg' }]} />)
|
||||
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(<SlashCommandMenu id="fallback-slash" groups={{ system: [], plugins: [{ name: 'hyperframes', description: 'Videos', kind: 'plugin' }], skills: [{ name: 'video', description: 'Make a video', kind: 'skill', source: 'user' }], ordered: [] }} selectedIndex={0} itemRefs={{ current: [] }} onSelect={vi.fn()} onHighlight={vi.fn()} showKeyboardHints={false} references={[]} />)
|
||||
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)[] }
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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<HTMLDivElement, SlashCommandMenuProps
|
||||
const index = group.offset + position
|
||||
const candidate = references.find(item => 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 <div
|
||||
id={getSlashCommandOptionId(id, index)} key={command.name} role="option" tabIndex={-1}
|
||||
aria-selected={index === selectedIndex} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
|
||||
ref={element => { 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 ? <img src={icon} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.8} />}
|
||||
{icon ? <img src={publicAssetPath(icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.8} />}
|
||||
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
|
||||
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
||||
{command.source ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(getSkillSourceLabelKey(command.source))}</span> : null}
|
||||
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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')
|
||||
|
||||
@@ -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: '<agent> <prompt>' },
|
||||
{ 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: '[<condition> | 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) => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 && <span className="mt-1 block text-[10px] leading-4 text-[var(--color-text-tertiary)]">{status}</span>}
|
||||
</span>
|
||||
</button>
|
||||
{action ?? <IconButton label={actionLabel} size="md" shape="circle" tone="secondary" bordered disabled={disabled} onClick={onAction} icon={<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{added ? <path d="m7 5 5 5-5 5" /> : <path d="M10 4v12M4 10h12" />}</svg>} />}
|
||||
{action ?? <IconButton label={actionLabel} size="md" shape="circle" tone="secondary" bordered onClick={onAction} icon={<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{added ? <path d="m7 5 5 5-5 5" /> : <path d="M10 4v12M4 10h12" />}</svg>} />}
|
||||
</article>
|
||||
}
|
||||
|
||||
@@ -166,15 +166,25 @@ it('still requires every setup field when a stored credential is being replaced'
|
||||
expect(perform).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('Workspace')).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
it('opens configuration details from add without automatically sending credentials', () => {
|
||||
it('opens configuration details from the row action without automatically sending credentials', () => {
|
||||
useConnectorStore.setState({ items: [{ ...remote, installed: false, status: 'not-installed' }] })
|
||||
render(<Connectors />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'connectors.action.prepare' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(perform).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'connectors.action.prepare' }).at(-1)!)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'connectors.action.prepare' }))
|
||||
expect(perform).toHaveBeenCalledWith('remote-search', 'prepare', {})
|
||||
})
|
||||
it('opens details from the row action without installing a connector that needs no configuration', () => {
|
||||
const plain: ConnectorDto = { ...remote, id: 'plain-service', displayName: 'Plain Service', setupFields: undefined }
|
||||
useConnectorStore.setState({ items: [{ ...plain, installed: false, status: 'not-installed' }] })
|
||||
render(<Connectors />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(perform).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'connectors.action.prepare' }))
|
||||
expect(perform).toHaveBeenCalledWith('plain-service', 'prepare', {})
|
||||
})
|
||||
it('renders a stable skeleton instead of empty catalog while loading', () => {
|
||||
useConnectorStore.setState({ items: [], loading: true })
|
||||
render(<Connectors />)
|
||||
|
||||
@@ -194,7 +194,7 @@ export function Connectors({ mode = 'plugins', embedded = false, externalQuery,
|
||||
</header>
|
||||
{(error || localError) && (!detail || confirmation) && <div id="connector-error" className="mb-4"><ErrorState size="sm" title={t('connectors.loadFailed')} detail={localError || error} onRetry={() => { setLocalError(null); void refresh() }} retryLabel={t('connectors.retry')} /></div>}
|
||||
{loading ? <div role="status" aria-label={t('connectors.loading')} className="grid grid-cols-1 gap-3 lg:grid-cols-2">{Array.from({ length: 8 }, (_, index) => <div key={index} className="flex h-28 items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] px-4"><Skeleton shape="block" width="40px" height="40px" /><div className="flex-1 space-y-2"><Skeleton width="40%" /><Skeleton /><Skeleton width="72%" /></div></div>)}</div>
|
||||
: visible.length ? <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">{visible.map(item => <ConnectorRow key={item.id} id={item.id} name={name(item)} description={nativeText(item, 'description')} kind={isTool(item) && mode !== 'skills' ? t('connectors.toolKind') : undefined} status={!item.supported ? t('connectors.unsupported') : item.installed || item.operation || item.status === 'error' ? statusLabel(item) : undefined} added={item.installed || !!item.operation || management} actionLabel={management || item.installed || item.operation ? t('connectors.details') : actionLabel(item, primaryAction(item))} disabled={!management && !item.supported && !item.installed} action={management && item.installed && !item.operation ? <Button size="sm" variant="secondary" disabled={pending[item.id]} onClick={() => requestAction(item, 'remove')}>{t('market.uninstall.action')}</Button> : undefined} onDetails={() => showDetail(item)} onAction={() => management || item.installed || item.operation ? showDetail(item) : requestAction(item, primaryAction(item) || 'prepare')} />)}</div>
|
||||
: visible.length ? <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">{visible.map(item => <ConnectorRow key={item.id} id={item.id} name={name(item)} description={nativeText(item, 'description')} kind={isTool(item) && mode !== 'skills' ? t('connectors.toolKind') : undefined} status={!item.supported ? t('connectors.unsupported') : item.installed || item.operation || item.status === 'error' ? statusLabel(item) : undefined} added={item.installed || !!item.operation || management} actionLabel={t('connectors.details')} action={management && item.installed && !item.operation ? <Button size="sm" variant="secondary" disabled={pending[item.id]} onClick={() => requestAction(item, 'remove')}>{t('market.uninstall.action')}</Button> : undefined} onDetails={() => showDetail(item)} onAction={() => showDetail(item)} />)}</div>
|
||||
: <EmptyState variant="plain" size="md" title={t('extensions.empty')} description={t(management ? (externalQuery ?? query).trim() ? 'connectors.emptySearch' : 'connectors.emptyAdded' : filter === 'added' ? 'connectors.emptyAdded' : 'connectors.emptySearch')} action={embedded || management ? undefined : { label: t('connectors.resetFilters'), variant: 'secondary', onClick: () => { setFilter('all'); setCategory('all'); setRegion('all'); setQuery('') } }} />}
|
||||
</div>
|
||||
<Modal open={!!detail && !confirmation} onClose={closeDetail} title={detail ? name(detail) : ''} width={600} footer={detail ? actions(detail) : undefined}>
|
||||
|
||||
Reference in New Issue
Block a user