Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/src/components/channels/ChannelConfigPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ChannelDefinition, ChannelType } from '../../types/channels';
import ChannelCapabilities from './ChannelCapabilities';
import DiscordConfig from './DiscordConfig';
import McpServersTab from './mcp/McpServersTab';
import TelegramConfig from './TelegramConfig';
import WebChannelConfig from './WebChannelConfig';

Expand All @@ -10,6 +11,25 @@ interface ChannelConfigPanelProps {
}

const ChannelConfigPanel = ({ selectedChannel, definitions }: ChannelConfigPanelProps) => {
// MCP is a virtual tab — not backed by a ChannelDefinition from the core.
if (selectedChannel === 'mcp') {
return (
<div className="space-y-4">
<section className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<div>
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
MCP Servers
</h3>
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
Browse and manage Model Context Protocol servers that extend the AI with new tools.
</p>
</div>
<McpServersTab />
</section>
</div>
);
}

const definition = definitions.find(d => d.id === selectedChannel);
if (!definition) return null;

Expand Down
33 changes: 31 additions & 2 deletions app/src/components/channels/ChannelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ interface ChannelSelectorProps {
onSelectChannel: (channel: ChannelType) => void;
}

const CHANNEL_ICONS: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐' };
const CHANNEL_ICONS: Record<string, string> = {
telegram: '✈️',
discord: '🎮',
web: '🌐',
mcp: '🔌',
};

/** Virtual (static) tabs that are not backed by a ChannelDefinition from the core. */
const VIRTUAL_TABS: { id: ChannelType; display_name: string }[] = [
{ id: 'mcp', display_name: 'MCP Servers' },
];
const CHANNEL_STATUS_PRIORITY: ChannelConnectionStatus[] = [
'connected',
'connecting',
Expand Down Expand Up @@ -48,7 +58,7 @@ const ChannelSelector = ({
</p>
</div>

<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
{definitions.map(def => {
const channelId = def.id as ChannelType;
const isSelected = selectedChannel === channelId;
Expand Down Expand Up @@ -81,6 +91,25 @@ const ChannelSelector = ({
</button>
);
})}

{/* Virtual tabs — not backed by a ChannelDefinition from the core */}
{VIRTUAL_TABS.map(tab => {
const isSelected = selectedChannel === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onSelectChannel(tab.id)}
className={`flex-1 flex items-center gap-2 rounded-lg border px-4 py-3 text-sm transition-colors ${
isSelected
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/15 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
<span className="text-base">{CHANNEL_ICONS[tab.id] ?? ''}</span>
<span className="font-medium">{tab.display_name}</span>
</button>
);
})}
</div>
</section>
);
Expand Down
135 changes: 135 additions & 0 deletions app/src/components/channels/mcp/ConfigAssistantPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import ConfigAssistantPanel from './ConfigAssistantPanel';

const mockConfigAssist = vi.fn();

vi.mock('../../../services/api/mcpClientsApi', () => ({
mcpClientsApi: { configAssist: (...args: unknown[]) => mockConfigAssist(...args) },
}));

describe('ConfigAssistantPanel', () => {
beforeEach(() => {
mockConfigAssist.mockReset();
});

it('renders the input textarea and Send button', () => {
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
});

it('send button is disabled when input is empty', () => {
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
});

it('enables send button when input has text', () => {
render(<ConfigAssistantPanel qualifiedName="acme/test" />);
fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
target: { value: 'What env vars do I need?' },
});
expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled();
});

it('sends message and renders assistant reply', async () => {
mockConfigAssist.mockResolvedValue({ reply: 'You need an API_KEY env var.' });
render(<ConfigAssistantPanel qualifiedName="acme/test" />);

fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
target: { value: 'What do I need?' },
});

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
});

await waitFor(() => {
expect(screen.getByText('You need an API_KEY env var.')).toBeInTheDocument();
});

expect(mockConfigAssist).toHaveBeenCalledWith({
qualified_name: 'acme/test',
user_message: 'What do I need?',
history: [{ role: 'user', content: 'What do I need?' }],
});
});

it('shows suggested_env values and Apply button', async () => {
mockConfigAssist.mockResolvedValue({
reply: 'Here are suggested values',
suggested_env: { API_KEY: 'abc123' },
});

const onApply = vi.fn();
render(<ConfigAssistantPanel qualifiedName="acme/test" onApplySuggestedEnv={onApply} />);

fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
target: { value: 'Help me configure' },
});

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
});

await waitFor(() => {
expect(screen.getByText('Here are suggested values')).toBeInTheDocument();
});

// Shows key name (the colon is in the same text node with whitespace)
expect(screen.getByText(/API_KEY:/)).toBeInTheDocument();

// Apply button exists and calls the callback
const applyBtn = screen.getByRole('button', { name: 'Apply suggested values' });
fireEvent.click(applyBtn);
expect(onApply).toHaveBeenCalledWith({ API_KEY: 'abc123' });
});

it('shows error on failed request', async () => {
mockConfigAssist.mockRejectedValue(new Error('AI service unavailable'));
render(<ConfigAssistantPanel qualifiedName="acme/test" />);

fireEvent.change(screen.getByPlaceholderText(/ask a question/i), {
target: { value: 'Hello' },
});

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
});

await waitFor(() => {
expect(screen.getByText('AI service unavailable')).toBeInTheDocument();
});
});

it('clears input after sending', async () => {
mockConfigAssist.mockResolvedValue({ reply: 'OK' });
render(<ConfigAssistantPanel qualifiedName="acme/test" />);

const textarea = screen.getByPlaceholderText(/ask a question/i) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'Question?' } });

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
});

expect(textarea.value).toBe('');
});

it('sends on Enter key press', async () => {
mockConfigAssist.mockResolvedValue({ reply: 'reply' });
render(<ConfigAssistantPanel qualifiedName="acme/test" />);

const textarea = screen.getByPlaceholderText(/ask a question/i);
fireEvent.change(textarea, { target: { value: 'test message' } });

await act(async () => {
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
});

await waitFor(() => {
expect(mockConfigAssist).toHaveBeenCalledTimes(1);
});
});
});
Loading