fix: skills/MCP/completion system overhaul — separate skill kinds, fix MCP remote servers, deduplicate completion processing
Skills system:
- Replace `CommandBadge.isSkill: boolean` with `kind: SkillKind` (agent_skill | slash_command | sdk_command | codepilot_command)
- handleSubmit dispatches by kind: agent_skill retains skill name in prompt, slash/sdk commands pass through to SDK, codepilot commands expand via COMMAND_PROMPTS
- Popover groups commands into Commands / Slash Commands / Agent Skills
- API `/api/skills` returns `kind` field; `displayOverride` persisted through full chain (frontend → API → DB)
- Forward SDK `system:init` slash_commands/skills metadata to frontend via SSE
MCP management:
- Fix remote server (SSE/HTTP) creation: validate `url` instead of `command`
- Fix dual-config read/write asymmetry: PUT routes servers by `_source` to correct file (~/.claude.json vs ~/.claude/settings.json)
- DELETE checks both config files; POST checks both for name collision
- McpManager preserves `_source` tags on edit/rename; JSON editor only manages settings.json servers
Completion processing:
- Extract core logic into `onboarding-processor.ts` and `checkin-processor.ts` (shared by route handlers and server-side detection)
- Remove frontend `detectAssistantCompletion` from ChatView — server-side path in `finally` block is now the single source of truth
- Replace hard-coded `fetch("http://localhost:PORT/...")` with direct function imports
- Both processors are internally idempotent (check state before processing)
Other:
- `settingSources` always includes 'user' so SDK loads ~/.claude/skills/
- Add 24 unit tests for SkillKind dispatch logic
- Add onboarding-completion extraction tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
789b453e92
commit
281e7f9502
24 files changed
+1972
-465
No files matched your search
@@ -102,6 +102,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const pendingImageNoticesRef = useRef<string[]>([]);
|
||||
// Ref for sendMessage to allow self-referencing in timeout auto-retry
|
||||
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
|
||||
// Init metadata from SDK (tools, slash_commands, skills) — populated on first status event
|
||||
const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null);
|
||||
|
||||
const handleModeChange = useCallback((newMode: string) => {
|
||||
setMode(newMode);
|
||||
@@ -151,7 +153,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// If stream completed while this ChatView was unmounted, consume finalMessageContent now.
|
||||
// Re-fetch messages from DB to avoid duplicates (backend already persisted the reply).
|
||||
if (existing.phase !== 'active' && existing.finalMessageContent) {
|
||||
detectAssistantCompletion(existing.finalMessageContent);
|
||||
// Completion processing (onboarding/checkin) is handled server-side only
|
||||
// in the chat API's finally block — no frontend duplicate needed.
|
||||
fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`)
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
@@ -200,8 +203,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// Append the final assistant message to the messages list
|
||||
const finalContent = event.snapshot.finalMessageContent;
|
||||
if (finalContent) {
|
||||
// Check for assistant project completion signals
|
||||
detectAssistantCompletion(finalContent);
|
||||
// Completion processing (onboarding/checkin) is handled server-side only.
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: 'temp-assistant-' + Date.now(),
|
||||
@@ -278,6 +280,41 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
|
||||
if (!needsOnboarding && !needsCheckIn) return;
|
||||
|
||||
// ── Compensation: check if a past message already contains a completion fence ──
|
||||
// This handles the case where the server-side detection also missed (e.g. crash/restart)
|
||||
// and the frontend is about to re-trigger onboarding unnecessarily.
|
||||
if (needsOnboarding && initialMessages.length > 0) {
|
||||
try {
|
||||
const { extractCompletion } = await import('@/lib/onboarding-completion');
|
||||
// Scan assistant messages from newest to oldest for an unprocessed completion
|
||||
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
||||
const msg = initialMessages[i];
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const completion = extractCompletion(msg.content);
|
||||
if (completion?.type === 'onboarding') {
|
||||
console.log('[ChatView] Found unprocessed onboarding completion in message history, compensating...');
|
||||
const resp = await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: completion.answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: '__clear__' }),
|
||||
}).catch(() => {});
|
||||
console.log('[ChatView] Onboarding compensation succeeded, skipping re-trigger');
|
||||
return; // Don't re-trigger onboarding
|
||||
}
|
||||
break; // Found fence but processing failed — fall through to re-trigger
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Onboarding compensation check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// For daily check-in, only trigger in the most recent session for this workspace.
|
||||
// This prevents older sessions from hijacking the check-in when reopened.
|
||||
if (needsCheckIn) {
|
||||
@@ -317,6 +354,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
|
||||
sendMessageRef.current?.(retryContent, retryFiles);
|
||||
},
|
||||
onInitMeta: (meta) => {
|
||||
initMetaRef.current = meta;
|
||||
console.log('[ChatView] SDK init meta received:', meta);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Assistant auto-trigger failed:', e);
|
||||
@@ -433,69 +474,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
[sessionId, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
// Detect assistant project completion signals in final message content.
|
||||
// Only processes fences when this session's workingDirectory matches the current assistant workspace path.
|
||||
const detectAssistantCompletion = useCallback(async (content: string) => {
|
||||
// Guard: only allow fence processing for the current assistant workspace session
|
||||
if (!workingDirectory) return;
|
||||
try {
|
||||
const wsRes = await fetch('/api/settings/workspace');
|
||||
if (!wsRes.ok) return;
|
||||
const wsData = await wsRes.json();
|
||||
if (!wsData.path || wsData.path !== workingDirectory) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearHookTriggered = () =>
|
||||
fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: '__clear__' }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Check for onboarding completion
|
||||
const onboardingMatch = content.match(/```onboarding-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
|
||||
if (onboardingMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(onboardingMatch[1].trim());
|
||||
const resp = await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await clearHookTriggered();
|
||||
} else {
|
||||
console.error('[ChatView] Onboarding API returned', resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Onboarding completion failed:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for check-in completion
|
||||
const checkinMatch = content.match(/```checkin-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
|
||||
if (checkinMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(checkinMatch[1].trim());
|
||||
const resp = await fetch('/api/workspace/checkin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
await clearHookTriggered();
|
||||
} else {
|
||||
console.error('[ChatView] Check-in API returned', resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Check-in completion failed:', e);
|
||||
}
|
||||
}
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Build SDK thinking config from settings
|
||||
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
|
||||
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
|
||||
@@ -550,6 +528,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
pendingImageNotices: notices,
|
||||
effort: selectedEffort,
|
||||
thinking: buildThinkingConfig(),
|
||||
displayOverride,
|
||||
onModeChanged: (sdkMode) => {
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
@@ -557,6 +536,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
|
||||
sendMessageRef.current?.(retryContent, retryFiles);
|
||||
},
|
||||
onInitMeta: (meta) => {
|
||||
initMetaRef.current = meta;
|
||||
console.log('[ChatView] SDK init meta received:', meta);
|
||||
},
|
||||
});
|
||||
},
|
||||
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
|
||||
@@ -734,6 +717,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
effort={selectedEffort}
|
||||
onEffortChange={setSelectedEffort}
|
||||
sdkInitMeta={initMetaRef.current}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
usePromptInputAttachments,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment, ProviderModelGroup } from '@/types';
|
||||
import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SlashCommandButton } from './SlashCommandButton';
|
||||
import { useImageGen } from '@/hooks/useImageGen';
|
||||
@@ -101,6 +101,8 @@ interface MessageInputProps {
|
||||
/** Effort selection lifted to parent for inclusion in the stream chain */
|
||||
effort?: string;
|
||||
onEffortChange?: (effort: string | undefined) => void;
|
||||
/** SDK init metadata — when available, used to validate command/skill availability */
|
||||
sdkInitMeta?: { tools?: unknown; slash_commands?: unknown; skills?: unknown } | null;
|
||||
}
|
||||
|
||||
interface PopoverItem {
|
||||
@@ -111,7 +113,8 @@ interface PopoverItem {
|
||||
builtIn?: boolean;
|
||||
immediate?: boolean;
|
||||
installedSource?: "agents" | "claude";
|
||||
source?: "global" | "project" | "plugin" | "installed";
|
||||
source?: "global" | "project" | "plugin" | "installed" | "sdk";
|
||||
kind?: SkillKind;
|
||||
icon?: typeof CommandLineIcon;
|
||||
}
|
||||
|
||||
@@ -119,7 +122,7 @@ interface CommandBadge {
|
||||
command: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isSkill: boolean;
|
||||
kind: SkillKind;
|
||||
installedSource?: "agents" | "claude";
|
||||
}
|
||||
|
||||
@@ -137,12 +140,12 @@ const BUILT_IN_COMMANDS: PopoverItem[] = [
|
||||
{ label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon },
|
||||
{ label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon },
|
||||
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon },
|
||||
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon },
|
||||
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon },
|
||||
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon },
|
||||
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon },
|
||||
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon },
|
||||
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon },
|
||||
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, kind: 'sdk_command', icon: FileZipIcon },
|
||||
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, kind: 'codepilot_command', icon: Stethoscope02Icon },
|
||||
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, kind: 'sdk_command', icon: FileEditIcon },
|
||||
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, kind: 'sdk_command', icon: SearchList01Icon },
|
||||
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, kind: 'codepilot_command', icon: CommandLineIcon },
|
||||
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, kind: 'codepilot_command', icon: BrainIcon },
|
||||
];
|
||||
|
||||
interface ModeOption {
|
||||
@@ -380,6 +383,7 @@ export function MessageInput({
|
||||
onAssistantTrigger,
|
||||
effort: effortProp,
|
||||
onEffortChange,
|
||||
sdkInitMeta,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageGen = useImageGen();
|
||||
@@ -489,25 +493,78 @@ export function MessageInput({
|
||||
const data = await res.json();
|
||||
const skills = data.skills || [];
|
||||
apiSkills = skills
|
||||
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed"; installedSource?: "agents" | "claude" }) => ({
|
||||
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed" | "sdk"; kind?: SkillKind; installedSource?: "agents" | "claude" }) => ({
|
||||
label: s.name,
|
||||
value: `/${s.name}`,
|
||||
description: s.description || "",
|
||||
builtIn: false,
|
||||
installedSource: s.installedSource,
|
||||
source: s.source,
|
||||
kind: s.kind || 'slash_command',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// API not available - just use built-in commands
|
||||
}
|
||||
|
||||
// When SDK init metadata is available, use it as the truth source:
|
||||
// 1. Filter out filesystem-scanned items that the SDK session didn't actually load
|
||||
// 2. Add any SDK-reported commands/skills missing from the filesystem scan
|
||||
// Note: SDK system:init reports slash_commands and skills as string[] (names only)
|
||||
if (sdkInitMeta) {
|
||||
const rawCmds = sdkInitMeta.slash_commands;
|
||||
const rawSkills = sdkInitMeta.skills;
|
||||
const sdkCommandNames = new Set(
|
||||
Array.isArray(rawCmds) ? rawCmds.map(c => typeof c === 'string' ? c : (c as { name?: string })?.name).filter(Boolean) as string[] : []
|
||||
);
|
||||
const sdkSkillNames = new Set(
|
||||
Array.isArray(rawSkills) ? rawSkills.map(s => typeof s === 'string' ? s : (s as { name?: string })?.name).filter(Boolean) as string[] : []
|
||||
);
|
||||
|
||||
// Only filter if SDK actually reported capabilities (non-empty arrays)
|
||||
if (sdkCommandNames.size > 0 || sdkSkillNames.size > 0) {
|
||||
apiSkills = apiSkills.filter(item => {
|
||||
if (item.kind === 'agent_skill') return sdkSkillNames.has(item.label);
|
||||
return sdkCommandNames.has(item.label);
|
||||
});
|
||||
}
|
||||
|
||||
const existingNames = new Set(apiSkills.map(s => s.label));
|
||||
|
||||
// Add SDK-reported commands not found in filesystem scan
|
||||
for (const cmdName of sdkCommandNames) {
|
||||
if (!existingNames.has(cmdName)) {
|
||||
apiSkills.push({
|
||||
label: cmdName,
|
||||
value: `/${cmdName}`,
|
||||
description: `SDK command: /${cmdName}`,
|
||||
builtIn: false,
|
||||
source: 'sdk',
|
||||
kind: 'sdk_command',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add SDK-reported skills not found in filesystem scan
|
||||
for (const skillName of sdkSkillNames) {
|
||||
if (!existingNames.has(skillName)) {
|
||||
apiSkills.push({
|
||||
label: skillName,
|
||||
value: `/${skillName}`,
|
||||
description: `Skill: /${skillName}`,
|
||||
builtIn: false,
|
||||
kind: 'agent_skill',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: remove API skills that share a name with built-in commands
|
||||
const builtInNames = new Set(BUILT_IN_COMMANDS.map(c => c.label));
|
||||
const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label));
|
||||
|
||||
return [...BUILT_IN_COMMANDS, ...uniqueSkills];
|
||||
}, [workingDirectory]);
|
||||
}, [workingDirectory, sdkInitMeta]);
|
||||
|
||||
// Close popover
|
||||
const closePopover = useCallback(() => {
|
||||
@@ -553,7 +610,7 @@ export function MessageInput({
|
||||
command: item.value,
|
||||
label: item.label,
|
||||
description: item.description || '',
|
||||
isSkill: !item.builtIn,
|
||||
kind: item.kind || 'slash_command',
|
||||
installedSource: item.installedSource,
|
||||
});
|
||||
setInputValue('');
|
||||
@@ -682,41 +739,49 @@ export function MessageInput({
|
||||
return;
|
||||
}
|
||||
|
||||
// If badge is active, expand the command/skill and send
|
||||
// If badge is active, dispatch by kind
|
||||
if (badge && !isStreaming) {
|
||||
let expandedPrompt = '';
|
||||
|
||||
if (badge.isSkill) {
|
||||
// Fetch skill content from API
|
||||
try {
|
||||
const detailParams = new URLSearchParams();
|
||||
if (badge.installedSource) detailParams.set("source", badge.installedSource);
|
||||
if (workingDirectory) detailParams.set("cwd", workingDirectory);
|
||||
const qs = detailParams.toString();
|
||||
const res = await fetch(
|
||||
`/api/skills/${encodeURIComponent(badge.label)}${qs ? `?${qs}` : ""}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
expandedPrompt = data.skill?.content || '';
|
||||
}
|
||||
} catch {
|
||||
// Fallback: use command name
|
||||
}
|
||||
} else {
|
||||
// Built-in prompt command expansion
|
||||
expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
||||
}
|
||||
|
||||
const finalPrompt = content
|
||||
? `${expandedPrompt}\n\nUser context: ${content}`
|
||||
: expandedPrompt || badge.command;
|
||||
|
||||
const files = await convertFiles();
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(finalPrompt, files.length > 0 ? files : undefined);
|
||||
return;
|
||||
const displayLabel = `/${badge.label}`;
|
||||
|
||||
switch (badge.kind) {
|
||||
case 'agent_skill': {
|
||||
// Agent skills: SDK loads SKILL.md natively from filesystem.
|
||||
// Always include skill name so Claude knows which skill was selected.
|
||||
// User context is appended after the skill reference.
|
||||
const agentPrompt = content
|
||||
? `Use the ${badge.label} skill. User context: ${content}`
|
||||
: `Please use the ${badge.label} skill.`;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(agentPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'slash_command':
|
||||
case 'sdk_command': {
|
||||
// Slash commands & SDK commands: send "/{command} {context}" as-is for SDK to handle
|
||||
const slashPrompt = content
|
||||
? `${badge.command} ${content}`
|
||||
: badge.command;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(slashPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'codepilot_command': {
|
||||
// CodePilot-specific commands: expand via COMMAND_PROMPTS, show /command in bubble
|
||||
const expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
||||
const finalPrompt = content
|
||||
? `${expandedPrompt}\n\nUser context: ${content}`
|
||||
: expandedPrompt || badge.command;
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(finalPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = await convertFiles();
|
||||
@@ -738,20 +803,20 @@ export function MessageInput({
|
||||
command: cmd.value,
|
||||
label: cmd.label,
|
||||
description: cmd.description || '',
|
||||
isSkill: false,
|
||||
kind: cmd.kind || 'sdk_command',
|
||||
});
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a built-in command — treat as a skill
|
||||
// Not a built-in command — default to slash_command (SDK will handle)
|
||||
const skillName = content.slice(1);
|
||||
if (skillName) {
|
||||
setBadge({
|
||||
command: content,
|
||||
label: skillName,
|
||||
description: '',
|
||||
isSkill: true,
|
||||
kind: 'slash_command',
|
||||
});
|
||||
setInputValue('');
|
||||
return;
|
||||
@@ -972,8 +1037,8 @@ export function MessageInput({
|
||||
{/* Popover */}
|
||||
{popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => {
|
||||
const builtInItems = filteredItems.filter(item => item.builtIn);
|
||||
const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project');
|
||||
const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project');
|
||||
const slashCommandItems = filteredItems.filter(item => !item.builtIn && item.kind !== 'agent_skill');
|
||||
const agentSkillItems = filteredItems.filter(item => !item.builtIn && item.kind === 'agent_skill');
|
||||
let globalIdx = 0;
|
||||
|
||||
const renderItem = (item: PopoverItem, idx: number) => (
|
||||
@@ -991,7 +1056,9 @@ export function MessageInput({
|
||||
<HugeiconsIcon icon={AtIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : item.builtIn && item.icon ? (
|
||||
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : !item.builtIn && item.source === 'project' ? (
|
||||
) : item.kind === 'agent_skill' ? (
|
||||
<HugeiconsIcon icon={BrainIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : item.kind === 'slash_command' ? (
|
||||
<HugeiconsIcon icon={FileEditIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : !item.builtIn ? (
|
||||
<HugeiconsIcon icon={GlobalIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -1077,23 +1144,23 @@ export function MessageInput({
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{projectItems.length > 0 && (
|
||||
{slashCommandItems.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Project Commands
|
||||
Slash Commands
|
||||
</div>
|
||||
{projectItems.map((item) => {
|
||||
{slashCommandItems.map((item) => {
|
||||
const idx = globalIdx++;
|
||||
return renderItem(item, idx);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{skillItems.length > 0 && (
|
||||
{agentSkillItems.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Skills
|
||||
Agent Skills
|
||||
</div>
|
||||
{skillItems.map((item) => {
|
||||
{agentSkillItems.map((item) => {
|
||||
const idx = globalIdx++;
|
||||
return renderItem(item, idx);
|
||||
})}
|
||||
|
||||
@@ -19,9 +19,11 @@ interface McpRuntimeStatus {
|
||||
serverInfo?: { name: string; version: string };
|
||||
}
|
||||
|
||||
type MCPServerWithSource = MCPServer & { _source?: string };
|
||||
|
||||
export function McpManager() {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Record<string, MCPServer>>({});
|
||||
const [servers, setServers] = useState<Record<string, MCPServerWithSource>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingName, setEditingName] = useState<string | undefined>();
|
||||
@@ -116,9 +118,11 @@ export function McpManager() {
|
||||
|
||||
async function handleSave(name: string, server: MCPServer) {
|
||||
if (editingName && editingName !== name) {
|
||||
// Rename: preserve _source from the original entry
|
||||
const original = servers[editingName];
|
||||
const updated = { ...servers };
|
||||
delete updated[editingName];
|
||||
updated[name] = server;
|
||||
updated[name] = original?._source ? { ...server, _source: original._source } : server;
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
@@ -130,7 +134,10 @@ export function McpManager() {
|
||||
console.error("Failed to save MCP server:", err);
|
||||
}
|
||||
} else if (editingName) {
|
||||
const updated = { ...servers, [name]: server };
|
||||
// Edit in-place: preserve _source
|
||||
const original = servers[editingName];
|
||||
const serverWithSource = original?._source ? { ...server, _source: original._source } : server;
|
||||
const updated = { ...servers, [name]: serverWithSource };
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
@@ -162,13 +169,26 @@ export function McpManager() {
|
||||
|
||||
async function handleJsonSave(jsonStr: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const parsed = JSON.parse(jsonStr) as Record<string, MCPServer>;
|
||||
// JSON editor only manages settings.json servers.
|
||||
// Merge back: keep claude.json servers untouched, replace settings.json servers.
|
||||
const claudeJsonServers: Record<string, MCPServerWithSource> = {};
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (server._source === 'claude.json') {
|
||||
claudeJsonServers[name] = server;
|
||||
}
|
||||
}
|
||||
const settingsServers: Record<string, MCPServerWithSource> = {};
|
||||
for (const [name, server] of Object.entries(parsed)) {
|
||||
settingsServers[name] = { ...server, _source: 'settings.json' };
|
||||
}
|
||||
const merged = { ...claudeJsonServers, ...settingsServers };
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpServers: parsed }),
|
||||
body: JSON.stringify({ mcpServers: merged }),
|
||||
});
|
||||
setServers(parsed);
|
||||
setServers(merged);
|
||||
} catch (err) {
|
||||
console.error("Failed to save MCP config:", err);
|
||||
}
|
||||
@@ -234,8 +254,25 @@ export function McpManager() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json" className="mt-4">
|
||||
{Object.values(servers).some(s => s._source === 'claude.json') && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Servers from ~/.claude.json are managed by Claude CLI and not shown here.
|
||||
Use the list tab to edit or delete them.
|
||||
</p>
|
||||
)}
|
||||
<ConfigEditor
|
||||
value={JSON.stringify(servers, null, 2)}
|
||||
value={JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(servers)
|
||||
.filter(([, v]) => v._source !== 'claude.json')
|
||||
.map(([k, v]) => {
|
||||
const { _source, ...rest } = v;
|
||||
return [k, rest];
|
||||
})
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
onSave={handleJsonSave}
|
||||
label={t('mcp.serverConfig')}
|
||||
/>
|
||||
|
||||
@@ -162,7 +162,6 @@ export function McpServerEditor({
|
||||
...(env ? { env } : {}),
|
||||
}
|
||||
: {
|
||||
command: '',
|
||||
type: serverType,
|
||||
...(url ? { url: url.trim() } : {}),
|
||||
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
|
||||
|
||||
Reference in new issue
Block a user