mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
- Added support for skills and MCP servers in the ChatComposer, allowing users to apply skills and select MCP servers through mentions. - Updated the HomeHero and ProjectView components to manage skill selection and project skill changes. - Enhanced the EntryShell and other components to accommodate new skill and MCP server functionalities. - Improved CSS styles for better visual presentation of new features. - Added tests to ensure proper functionality of skill and MCP server integrations within the ChatComposer. This update significantly improves the user experience by enabling seamless integration of skills and MCP servers into the chat interface, enhancing project management capabilities.
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import type { ComponentProps } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ChatComposer } from '../../src/components/ChatComposer';
|
|
|
|
const COMMUNITY_PLUGIN = {
|
|
id: 'community-deck',
|
|
title: 'Community Deck',
|
|
version: '1.0.0',
|
|
trust: 'restricted' as const,
|
|
sourceKind: 'bundled' as const,
|
|
source: 'bundled/community-deck',
|
|
capabilitiesGranted: [],
|
|
manifest: {
|
|
name: 'community-deck',
|
|
title: 'Community Deck',
|
|
description: 'Official deck starter',
|
|
od: { kind: 'skill' },
|
|
},
|
|
fsPath: '/plugins/community-deck',
|
|
installedAt: 0,
|
|
updatedAt: 0,
|
|
};
|
|
|
|
const USER_PLUGIN = {
|
|
...COMMUNITY_PLUGIN,
|
|
id: 'my-export',
|
|
title: 'My Export',
|
|
sourceKind: 'local' as const,
|
|
source: '/plugins/my-export',
|
|
manifest: {
|
|
...COMMUNITY_PLUGIN.manifest,
|
|
name: 'my-export',
|
|
title: 'My Export',
|
|
description: 'Private export workflow',
|
|
},
|
|
};
|
|
|
|
const SKILL = {
|
|
id: 'deck-builder',
|
|
name: 'Deck Builder',
|
|
description: 'Build a polished slide deck.',
|
|
triggers: ['deck'],
|
|
mode: 'deck' as const,
|
|
previewType: 'html',
|
|
designSystemRequired: false,
|
|
defaultFor: [],
|
|
upstream: null,
|
|
hasBody: true,
|
|
examplePrompt: 'Make a deck',
|
|
aggregatesExamples: false,
|
|
};
|
|
|
|
const MCP_SERVER = {
|
|
id: 'slack',
|
|
label: 'Slack MCP',
|
|
transport: 'stdio' as const,
|
|
enabled: true,
|
|
command: 'slack-mcp',
|
|
};
|
|
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
let plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
|
|
let skills = [SKILL];
|
|
let servers = [MCP_SERVER];
|
|
|
|
function renderComposer(overrides: Partial<ComponentProps<typeof ChatComposer>> = {}) {
|
|
return render(
|
|
<ChatComposer
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
streaming={false}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={vi.fn()}
|
|
onStop={vi.fn()}
|
|
onOpenMcpSettings={vi.fn()}
|
|
{...overrides}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
plugins = [COMMUNITY_PLUGIN, USER_PLUGIN];
|
|
skills = [SKILL];
|
|
servers = [MCP_SERVER];
|
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
if (url === '/api/mcp/servers') {
|
|
return new Response(JSON.stringify({ servers, templates: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/plugins') {
|
|
return new Response(JSON.stringify({ plugins }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/skills') {
|
|
return new Response(JSON.stringify({ skills }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/projects/project-1' && init?.method === 'PATCH') {
|
|
return new Response(JSON.stringify({ project: { id: 'project-1', skillId: SKILL.id } }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
cleanup();
|
|
});
|
|
|
|
describe('ChatComposer context pickers', () => {
|
|
it('opens the @ panel even when every source is empty', async () => {
|
|
plugins = [];
|
|
skills = [];
|
|
servers = [];
|
|
renderComposer();
|
|
|
|
fireEvent.change(screen.getByTestId('chat-composer-input'), {
|
|
target: { value: '@', selectionStart: 1 },
|
|
});
|
|
|
|
expect(screen.getByTestId('mention-popover')).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Plugins' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Skills' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'MCP' })).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Design files' })).toBeTruthy();
|
|
expect(screen.getByText('Search plugins, skills, MCP servers, and Design Files.')).toBeTruthy();
|
|
});
|
|
|
|
it('selects an MCP server from @ search and inserts a tool hint', async () => {
|
|
renderComposer();
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@sl', selectionStart: 3 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Slack MCP')).toBeTruthy());
|
|
fireEvent.click(screen.getByText('Slack MCP'));
|
|
|
|
expect(input.value).toBe('Use the `slack` MCP server tools. ');
|
|
});
|
|
|
|
it('applies a skill from @ search and reports the active project skill', async () => {
|
|
const onProjectSkillChange = vi.fn();
|
|
renderComposer({ onProjectSkillChange });
|
|
const input = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
|
|
|
|
fireEvent.change(input, {
|
|
target: { value: '@deck', selectionStart: 5 },
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText('Deck Builder')).toBeTruthy());
|
|
fireEvent.click(screen.getByText('Deck Builder'));
|
|
|
|
await waitFor(() => expect(onProjectSkillChange).toHaveBeenCalledWith('deck-builder'));
|
|
expect(input.value).toBe('Use the @deck-builder skill. ');
|
|
});
|
|
|
|
it('lets the tools panel switch between Community and My plugins', async () => {
|
|
renderComposer();
|
|
fireEvent.click(screen.getByLabelText('Open CLI and model settings'));
|
|
|
|
await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy());
|
|
expect(screen.queryByText('My Export')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByText('My plugins'));
|
|
expect(screen.getByText('My Export')).toBeTruthy();
|
|
expect(screen.queryByText('Community Deck')).toBeNull();
|
|
|
|
fireEvent.change(screen.getByLabelText('Search plugins'), {
|
|
target: { value: 'private' },
|
|
});
|
|
expect(screen.getByText('Private export workflow')).toBeTruthy();
|
|
});
|
|
});
|