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:
程序员阿江(Relakkes)
2026-09-14 12:10:12 +08:00
parent f0a016d3cf
commit 0e46f7707d
11 changed files with 205 additions and 57 deletions
+2 -2
View File
@@ -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')
+53 -18
View File
@@ -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>
}
+13 -3
View File
@@ -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 />)
+1 -1
View File
@@ -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}>