cc-haha/desktop/src/components/chat/LocalSlashCommandPanel.tsx
程序员阿江(Relakkes) 66edded2d7 Make desktop MCP management and slash entry points directly operable
Desktop MCP management now has a working server API, a global-only settings surface,
and slash-command entry points that surface MCP and skills from the composer before
routing users into the right settings view.

Constraint: Project-scoped MCP browsing in settings was too slow and noisy because it scanned multiple workdirs
Rejected: Keep project MCP aggregation on the settings homepage | duplicated entries and poor responsiveness
Rejected: Route /mcp directly on Enter without an intermediate card | removed the user's ability to choose a specific target first
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep settings focused on global MCP; add project-scoped MCP affordances in the chat-context slash surfaces instead of re-expanding the settings homepage
Tested: bun test src/server/__tests__/mcp.test.ts; cd desktop && bun run test -- pages.test.tsx mcpSettings.test.tsx; cd desktop && bun x tsc --noEmit --ignoreDeprecations 5.0
Not-tested: Manual IAB verification after this final commit/merge cycle
2026-04-21 23:28:06 +08:00

286 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { skillsApi } from '../../api/skills'
import { mcpApi } from '../../api/mcp'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useMcpStore } from '../../stores/mcpStore'
import { useSkillStore } from '../../stores/skillStore'
import type { McpServerRecord } from '../../types/mcp'
import type { SkillMeta } from '../../types/skill'
export type LocalSlashCommandName = 'mcp' | 'skills' | 'plugins'
type Props = {
command: LocalSlashCommandName
cwd?: string
onClose: () => void
}
function toneForStatus(status: McpServerRecord['status']) {
switch (status) {
case 'connected':
return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
case 'needs-auth':
return 'bg-amber-500/10 text-amber-600 border-amber-500/20'
case 'failed':
return 'bg-rose-500/10 text-rose-600 border-rose-500/20'
case 'disabled':
return 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]'
default:
return ''
}
}
function scopeLabel(scope: string, t: ReturnType<typeof useTranslation>) {
switch (scope) {
case 'user':
return t('settings.mcp.scope.user')
case 'local':
return t('settings.mcp.scope.local')
case 'project':
return t('settings.mcp.scope.project')
default:
return scope
}
}
function projectBadge(path?: string, t?: ReturnType<typeof useTranslation>) {
if (!path || !t) return null
const label = path.replace(/\/$/, '').split('/').pop() || path
return t('slash.mcp.projectBadge', { name: label })
}
function PanelShell({
title,
subtitle,
children,
onClose,
}: {
title: string
subtitle: string
children: React.ReactNode
onClose: () => void
}) {
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-3 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
</div>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<div className="max-h-[420px] overflow-y-auto px-5 py-4">{children}</div>
</div>
)
}
function LoadingState({ label }: { label: string }) {
return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-tertiary)]">
<div className="mr-3 h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
{label}
</div>
)
}
function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-10 text-center">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{body}</div>
</div>
)
}
function ErrorState({ message }: { message: string }) {
return (
<div className="rounded-2xl border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-5 py-4 text-sm text-[var(--color-error)]">
{message}
</div>
)
}
function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
const t = useTranslation()
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
const selectServer = useMcpStore((s) => s.selectServer)
const [servers, setServers] = useState<McpServerRecord[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
mcpApi.list(cwd)
.then((response) => {
if (cancelled) return
setServers(response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project'))
})
.catch((err) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [cwd])
const grouped = useMemo(() => {
const groups = new Map<string, McpServerRecord[]>()
for (const server of servers ?? []) {
const key = server.scope
const existing = groups.get(key) ?? []
existing.push(server)
groups.set(key, existing)
}
return groups
}, [servers])
return (
<PanelShell
title={t('slash.mcp.title')}
subtitle={cwd ? t('slash.mcp.subtitleWithProject', { path: cwd }) : t('slash.mcp.subtitle')}
onClose={onClose}
>
{error ? (
<ErrorState message={error} />
) : servers === null ? (
<LoadingState label={t('common.loading')} />
) : servers.length === 0 ? (
<EmptyState title={t('slash.mcp.emptyTitle')} body={t('slash.mcp.emptyBody')} />
) : (
<div className="space-y-5">
{['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => (
<section key={scope}>
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{scopeLabel(scope, t)}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{grouped.get(scope)?.length ?? 0}</div>
</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{grouped.get(scope)?.map((server) => (
<button
type="button"
key={`${server.scope}:${server.projectPath ?? 'global'}:${server.name}`}
onClick={() => {
selectServer(server)
setPendingSettingsTab('mcp')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{server.name}</div>
<span className={`inline-flex items-center rounded-full border px-2 py-1 text-[11px] font-semibold ${toneForStatus(server.status)}`}>
{server.statusLabel}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1">{server.transport}</span>
{server.projectPath && (
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1" title={server.projectPath}>
{projectBadge(server.projectPath, t)}
</span>
)}
<span className="truncate">{server.summary}</span>
</div>
</button>
))}
</div>
</section>
))}
</div>
)}
</PanelShell>
)
}
function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
const t = useTranslation()
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
const [skills, setSkills] = useState<SkillMeta[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
skillsApi.list(cwd)
.then((response) => {
if (cancelled) return
setSkills(response.skills.filter((skill) => skill.userInvocable))
})
.catch((err) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
return () => {
cancelled = true
}
}, [cwd])
return (
<PanelShell
title={t('slash.skills.title')}
subtitle={cwd ? t('slash.skills.subtitleWithProject', { path: cwd }) : t('slash.skills.subtitle')}
onClose={onClose}
>
{error ? (
<ErrorState message={error} />
) : skills === null ? (
<LoadingState label={t('common.loading')} />
) : skills.length === 0 ? (
<EmptyState title={t('slash.skills.emptyTitle')} body={t('slash.skills.emptyBody')} />
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
{skills.map((skill) => (
<button
type="button"
key={`${skill.source}:${skill.name}`}
onClick={async () => {
await fetchSkillDetail(skill.source, skill.name, cwd)
setPendingSettingsTab('skills')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">/{skill.name}</div>
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]">
{skill.source}
</span>
</div>
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{skill.description}</div>
</button>
))}
</div>
)}
</PanelShell>
)
}
function PluginsPanel({ onClose }: { onClose: () => void }) {
const t = useTranslation()
return (
<PanelShell
title={t('slash.plugins.title')}
subtitle={t('slash.plugins.subtitle')}
onClose={onClose}
>
<EmptyState title={t('slash.plugins.emptyTitle')} body={t('slash.plugins.emptyBody')} />
</PanelShell>
)
}
export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) {
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
return <PluginsPanel onClose={onClose} />
}