mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp): search + keyboard navigation for installed MCP servers (#2639)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5bd47bbcf1
commit
55b1e6a30d
@@ -183,8 +183,9 @@ describe('InstalledServerList', () => {
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
// The status dot has title="error"
|
||||
expect(screen.getByTitle('error')).toBeInTheDocument();
|
||||
// The status dot title is the i18n'd label ('Error' in English) —
|
||||
// sourced from `channels.status.error` per `STATUS_I18N_KEYS`.
|
||||
expect(screen.getByTitle('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to disconnected status when no matching status entry', () => {
|
||||
@@ -197,7 +198,7 @@ describe('InstalledServerList', () => {
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTitle('disconnected')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -234,4 +235,287 @@ describe('InstalledServerList', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Browse catalog' }));
|
||||
expect(onBrowse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Filter behaviour (the new search/filter feature)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('shows all servers when filter is the empty string', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter=""
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
expect(screen.getByText('DB Server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by display_name case-insensitively', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="FILE"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by qualified_name', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="db-server"
|
||||
/>
|
||||
);
|
||||
// SERVER_2 has qualified_name 'acme/db-server' — matched
|
||||
expect(screen.getByText('DB Server')).toBeInTheDocument();
|
||||
expect(screen.queryByText('File Server')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by description', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="reads files"
|
||||
/>
|
||||
);
|
||||
// SERVER_1 has description 'Reads files' — matched
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats undefined description as empty (no false match) without crashing', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_2]} // description: undefined
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="undefined"
|
||||
/>
|
||||
);
|
||||
// 'undefined' must not match the absent description literally — assertion
|
||||
// here is that the filter logic doesn't blow up and the no-match path runs.
|
||||
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the filter', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter=" File "
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('File Server')).toBeInTheDocument();
|
||||
expect(screen.queryByText('DB Server')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "no matches" message including the query when filter matches nothing', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="zzz-nope"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No servers match "zzz-nope".')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "X of Y servers" count via an aria-live region when filtering', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="File"
|
||||
/>
|
||||
);
|
||||
// `status` is NOT a "name from content" role per WAI-ARIA, so the
|
||||
// accessible name doesn't come from text. Query by text and then
|
||||
// verify the live-region attributes on the same element.
|
||||
const status = screen.getByText('1 of 2 servers');
|
||||
expect(status).toHaveAttribute('role', 'status');
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('hides the count when filter is empty', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter=""
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/of \d+ servers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the count when filter is only whitespace', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter=" "
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/of \d+ servers/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the original empty state (not the filtered no-match) when there are zero servers', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="anything"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No servers match/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Keyboard navigation (ArrowUp / ArrowDown across server buttons)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('moves focus to the next server on ArrowDown', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
const first = screen.getByRole('button', { name: /File Server/i });
|
||||
const second = screen.getByRole('button', { name: /DB Server/i });
|
||||
first.focus();
|
||||
expect(first).toHaveFocus();
|
||||
fireEvent.keyDown(first, { key: 'ArrowDown' });
|
||||
expect(second).toHaveFocus();
|
||||
});
|
||||
|
||||
it('moves focus to the previous server on ArrowUp', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
const first = screen.getByRole('button', { name: /File Server/i });
|
||||
const second = screen.getByRole('button', { name: /DB Server/i });
|
||||
second.focus();
|
||||
fireEvent.keyDown(second, { key: 'ArrowUp' });
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
it('clamps focus at the last server on ArrowDown', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
const second = screen.getByRole('button', { name: /DB Server/i });
|
||||
second.focus();
|
||||
fireEvent.keyDown(second, { key: 'ArrowDown' });
|
||||
// No wrap-around.
|
||||
expect(second).toHaveFocus();
|
||||
});
|
||||
|
||||
it('clamps focus at the first server on ArrowUp', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
const first = screen.getByRole('button', { name: /File Server/i });
|
||||
first.focus();
|
||||
fireEvent.keyDown(first, { key: 'ArrowUp' });
|
||||
expect(first).toHaveFocus();
|
||||
});
|
||||
|
||||
it('does not move focus or preventDefault for unrelated keys', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
/>
|
||||
);
|
||||
const first = screen.getByRole('button', { name: /File Server/i });
|
||||
first.focus();
|
||||
const event = fireEvent.keyDown(first, { key: 'a' });
|
||||
// The listener should ignore unrelated keys; focus stays put.
|
||||
expect(first).toHaveFocus();
|
||||
// fireEvent returns false if preventDefault was called — verify it wasn't.
|
||||
expect(event).toBe(true);
|
||||
});
|
||||
|
||||
it('arrow keys traverse only the visible (filtered) items', () => {
|
||||
render(
|
||||
<InstalledServerList
|
||||
servers={[SERVER_1, SERVER_2]}
|
||||
statuses={[]}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
onBrowseCatalog={() => {}}
|
||||
filter="File"
|
||||
/>
|
||||
);
|
||||
const visible = screen.getByRole('button', { name: /File Server/i });
|
||||
visible.focus();
|
||||
// Only one filtered item → ArrowDown should clamp (single visible)
|
||||
fireEvent.keyDown(visible, { key: 'ArrowDown' });
|
||||
expect(visible).toHaveFocus();
|
||||
expect(screen.queryByRole('button', { name: /DB Server/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/**
|
||||
* List of installed MCP servers with status dot, name, and tool count.
|
||||
*
|
||||
* Supports an optional `filter` prop that case-insensitively matches
|
||||
* against `display_name`, `qualified_name`, and `description`. When
|
||||
* filtering is active the list shows a "X of Y servers" count via a
|
||||
* `role="status"` live region so assistive tech announces the new
|
||||
* total as the user types. ArrowUp / ArrowDown move focus between
|
||||
* server buttons (clamped at the edges); Enter/Space activate via
|
||||
* the underlying `<button>` semantics.
|
||||
*/
|
||||
import { type KeyboardEvent as ReactKeyboardEvent, useMemo, useRef } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { ConnStatus, InstalledServer, ServerStatus } from './types';
|
||||
|
||||
@@ -10,6 +20,8 @@ interface InstalledServerListProps {
|
||||
selectedId: string | null;
|
||||
onSelect: (serverId: string) => void;
|
||||
onBrowseCatalog: () => void;
|
||||
/** Optional case-insensitive filter applied to display_name / qualified_name / description. */
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<ServerStatus, string> = {
|
||||
@@ -19,15 +31,57 @@ const STATUS_DOT: Record<ServerStatus, string> = {
|
||||
error: 'bg-coral-500',
|
||||
};
|
||||
|
||||
// i18n keys for the per-status tooltip on the status dot. Reuses the
|
||||
// existing `channels.status.*` namespace (already shipped as the canonical
|
||||
// status vocabulary by McpStatusBadge) so we don't fork translations for
|
||||
// the same four words.
|
||||
const STATUS_I18N_KEYS: Record<ServerStatus, string> = {
|
||||
connected: 'channels.status.connected',
|
||||
connecting: 'channels.status.connecting',
|
||||
disconnected: 'channels.status.disconnected',
|
||||
error: 'channels.status.error',
|
||||
};
|
||||
|
||||
const InstalledServerList = ({
|
||||
servers,
|
||||
statuses,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onBrowseCatalog,
|
||||
filter = '',
|
||||
}: InstalledServerListProps) => {
|
||||
const { t } = useT();
|
||||
const statusMap = new Map((statuses ?? []).map(s => [s.server_id, s]));
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const statusMap = useMemo(() => new Map((statuses ?? []).map(s => [s.server_id, s])), [statuses]);
|
||||
|
||||
const trimmedFilter = filter.trim().toLowerCase();
|
||||
const isFiltering = trimmedFilter.length > 0;
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!trimmedFilter) return servers;
|
||||
return servers.filter(s => {
|
||||
const haystack = [s.display_name, s.qualified_name, s.description ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(trimmedFilter);
|
||||
});
|
||||
}, [servers, trimmedFilter]);
|
||||
|
||||
const handleItemKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
const root = listRef.current;
|
||||
if (!root) return;
|
||||
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-server-id]'));
|
||||
const currentIdx = buttons.indexOf(event.currentTarget);
|
||||
if (currentIdx < 0) return;
|
||||
event.preventDefault();
|
||||
const nextIdx =
|
||||
event.key === 'ArrowDown'
|
||||
? Math.min(currentIdx + 1, buttons.length - 1)
|
||||
: Math.max(currentIdx - 1, 0);
|
||||
if (nextIdx !== currentIdx) {
|
||||
buttons[nextIdx].focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -54,46 +108,68 @@ const InstalledServerList = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1 flex-1 overflow-y-auto">
|
||||
{servers.map(server => {
|
||||
const connStatus = statusMap.get(server.server_id);
|
||||
const status: ServerStatus = connStatus?.status ?? 'disconnected';
|
||||
const toolCount = connStatus?.tool_count ?? 0;
|
||||
const isSelected = selectedId === server.server_id;
|
||||
<>
|
||||
{isFiltering && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mb-2 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('mcp.installed.search.countMatches')
|
||||
.replace('{shown}', String(filteredServers.length))
|
||||
.replace('{total}', String(servers.length))}
|
||||
</p>
|
||||
)}
|
||||
{filteredServers.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-center py-8">
|
||||
<p className="text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('mcp.installed.search.noMatches').replace('{query}', filter.trim())}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul ref={listRef} className="space-y-1 flex-1 overflow-y-auto">
|
||||
{filteredServers.map(server => {
|
||||
const connStatus = statusMap.get(server.server_id);
|
||||
const status: ServerStatus = connStatus?.status ?? 'disconnected';
|
||||
const toolCount = connStatus?.tool_count ?? 0;
|
||||
const isSelected = selectedId === server.server_id;
|
||||
|
||||
return (
|
||||
<li key={server.server_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(server.server_id)}
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary-50 dark:bg-primary-500/15 border border-primary-200 dark:border-primary-500/30'
|
||||
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 border border-transparent'
|
||||
}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
|
||||
title={status}
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{status === 'connected' && toolCount > 0 && (
|
||||
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t(
|
||||
toolCount === 1
|
||||
? 'mcp.installed.toolSingular'
|
||||
: 'mcp.installed.toolPlural'
|
||||
).replace('{count}', String(toolCount))}
|
||||
return (
|
||||
<li key={server.server_id}>
|
||||
<button
|
||||
type="button"
|
||||
data-server-id={server.server_id}
|
||||
onClick={() => onSelect(server.server_id)}
|
||||
onKeyDown={handleItemKeyDown}
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary-50 dark:bg-primary-500/15 border border-primary-200 dark:border-primary-500/30'
|
||||
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60 border border-transparent'
|
||||
}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
|
||||
title={t(STATUS_I18N_KEYS[status])}
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100 truncate">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{status === 'connected' && toolCount > 0 && (
|
||||
<span className="block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t(
|
||||
toolCount === 1
|
||||
? 'mcp.installed.toolSingular'
|
||||
: 'mcp.installed.toolPlural'
|
||||
).replace('{count}', String(toolCount))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Tests for McpServerSearch — controlled filter input with clear button.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import McpServerSearch from './McpServerSearch';
|
||||
|
||||
describe('McpServerSearch', () => {
|
||||
it('renders the search landmark with an accessible label', () => {
|
||||
render(<McpServerSearch value="" onChange={() => {}} />);
|
||||
const landmark = screen.getByRole('search', { name: 'Search installed MCP servers' });
|
||||
expect(landmark).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a search-type input with placeholder and aria-label', () => {
|
||||
render(<McpServerSearch value="" onChange={() => {}} />);
|
||||
const input = screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' });
|
||||
expect(input).toHaveAttribute('type', 'search');
|
||||
expect(input).toHaveAttribute('placeholder', 'Filter servers…');
|
||||
});
|
||||
|
||||
it('does not render the clear button when value is empty', () => {
|
||||
render(<McpServerSearch value="" onChange={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the clear button when value is non-empty', () => {
|
||||
render(<McpServerSearch value="redis" onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'Clear filter' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the current value as the input value', () => {
|
||||
render(<McpServerSearch value="redis" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' })
|
||||
).toHaveValue('redis');
|
||||
});
|
||||
|
||||
it('fires onChange with the new value on typing', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<McpServerSearch value="" onChange={onChange} />);
|
||||
const input = screen.getByRole('searchbox', { name: 'Filter installed MCP servers by name' });
|
||||
fireEvent.change(input, { target: { value: 'gh' } });
|
||||
expect(onChange).toHaveBeenCalledWith('gh');
|
||||
});
|
||||
|
||||
it('fires onChange with an empty string when the clear button is clicked', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<McpServerSearch value="redis" onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear filter' }));
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('renders the clear icon as decorative (aria-hidden)', () => {
|
||||
const { container } = render(<McpServerSearch value="x" onChange={() => {}} />);
|
||||
// The svg inside the clear button must be aria-hidden so the button's
|
||||
// own aria-label is the sole accessible name.
|
||||
const svg = container.querySelector('button[aria-label="Clear filter"] svg');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Filter input for the installed MCP server list.
|
||||
*
|
||||
* Controlled component — the parent (`McpServersTab`) owns the value
|
||||
* and pushes it into `InstalledServerList` as the `filter` prop. The
|
||||
* input exposes a clear button when non-empty and announces itself
|
||||
* as a `role="search"` landmark so assistive tech can jump to it.
|
||||
*
|
||||
* Intentionally has NO global keyboard shortcut binding (e.g. Cmd/Ctrl+K)
|
||||
* to avoid clashing with the app-wide CommandProvider in `App.tsx`.
|
||||
* Users focus the input by clicking or tabbing.
|
||||
*/
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
|
||||
interface McpServerSearchProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}
|
||||
|
||||
const McpServerSearch = ({ value, onChange }: McpServerSearchProps) => {
|
||||
const { t } = useT();
|
||||
const hasValue = value.length > 0;
|
||||
return (
|
||||
<div role="search" aria-label={t('mcp.installed.search.landmarkAria')} className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={t('mcp.installed.search.placeholder')}
|
||||
aria-label={t('mcp.installed.search.inputAria')}
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 pr-7 text-xs text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:border-primary-400"
|
||||
/>
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('mcp.installed.search.clearAria')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-stone-400 hover:text-stone-700 dark:text-neutral-500 dark:hover:text-neutral-200 transition-colors">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpServerSearch;
|
||||
@@ -14,6 +14,7 @@ import InstalledServerDetail from './InstalledServerDetail';
|
||||
import InstalledServerList from './InstalledServerList';
|
||||
import McpCatalogBrowser from './McpCatalogBrowser';
|
||||
import McpInventoryPanel from './McpInventoryPanel';
|
||||
import McpServerSearch from './McpServerSearch';
|
||||
import type { ConnStatus, InstalledServer } from './types';
|
||||
|
||||
const log = debug('mcp-clients:tab');
|
||||
@@ -32,6 +33,9 @@ const McpServersTab = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [rightPane, setRightPane] = useState<RightPane>({ mode: 'none' });
|
||||
// Local-only filter for the installed-server list. Not persisted — the
|
||||
// search is a transient scan helper, not a saved view.
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
// Sharable Inventory modal toggle. Local state — the manifest UX is
|
||||
// a one-off interaction, not a saved view.
|
||||
const [inventoryOpen, setInventoryOpen] = useState(false);
|
||||
@@ -167,19 +171,25 @@ const McpServersTab = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left pane: installed list */}
|
||||
{/* Left pane: search + installed list */}
|
||||
<div className="w-56 shrink-0 flex flex-col">
|
||||
{loadError && (
|
||||
<div className="mb-2 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
{servers.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<McpServerSearch value={searchFilter} onChange={setSearchFilter} />
|
||||
</div>
|
||||
)}
|
||||
<InstalledServerList
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
onBrowseCatalog={handleBrowseCatalog}
|
||||
filter={searchFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1318,6 +1318,12 @@ const ar1: TranslationMap = {
|
||||
'mcp.installed.empty': 'لم يتم تثبيت خوادم MCP حتى الآن.',
|
||||
'mcp.installed.toolSingular': 'أداة {count}',
|
||||
'mcp.installed.toolPlural': 'أدوات {count}',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1327,6 +1327,12 @@ const bn1: TranslationMap = {
|
||||
'mcp.installed.empty': 'এখনো কোনো MCP সার্ভার ইনস্টল করা হয়নি।',
|
||||
'mcp.installed.toolSingular': '{count} টুল',
|
||||
'mcp.installed.toolPlural': '{count} টুল',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1345,6 +1345,12 @@ const de1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Noch keine MCP-Server installiert.',
|
||||
'mcp.installed.toolSingular': '{count}-Tool',
|
||||
'mcp.installed.toolPlural': '{count}-Tools',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1303,6 +1303,12 @@ const en1: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': '{count} tool',
|
||||
'mcp.installed.toolPlural': '{count} tools',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1342,6 +1342,12 @@ const es1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Aún no hay servidores MCP instalados.',
|
||||
'mcp.installed.toolSingular': 'herramienta {count}',
|
||||
'mcp.installed.toolPlural': '{count} herramientas',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1347,6 +1347,12 @@ const fr1: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': 'Outil {count}',
|
||||
'mcp.installed.toolPlural': 'Outils {count}',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1326,6 +1326,12 @@ const hi1: TranslationMap = {
|
||||
'mcp.installed.empty': 'अभी तक कोई MCP सर्वर स्थापित नहीं है।',
|
||||
'mcp.installed.toolSingular': '{count} उपकरण',
|
||||
'mcp.installed.toolPlural': '{count} उपकरण',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1330,6 +1330,12 @@ const id1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Belum ada server MCP yang terinstal.',
|
||||
'mcp.installed.toolSingular': '{count} alat',
|
||||
'mcp.installed.toolPlural': '{count} alat',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1335,6 +1335,12 @@ const it1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Nessun server MCP ancora installato.',
|
||||
'mcp.installed.toolSingular': '{count} strumento',
|
||||
'mcp.installed.toolPlural': '{count} strumenti',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1327,6 +1327,12 @@ const ko1: TranslationMap = {
|
||||
'mcp.installed.empty': '아직 MCP 서버가 설치되지 않았습니다.',
|
||||
'mcp.installed.toolSingular': '{count} 도구',
|
||||
'mcp.installed.toolPlural': '{count} 도구',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1340,6 +1340,12 @@ const pt1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Nenhum servidor MCP instalado ainda.',
|
||||
'mcp.installed.toolSingular': 'Ferramenta {count}',
|
||||
'mcp.installed.toolPlural': 'Ferramentas {count}',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1330,6 +1330,12 @@ const ru1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Серверы MCP пока не установлены.',
|
||||
'mcp.installed.toolSingular': '{count} инструмент',
|
||||
'mcp.installed.toolPlural': '{count} инструменты',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -1311,6 +1311,12 @@ const zhCN1: TranslationMap = {
|
||||
'mcp.installed.empty': '尚未安装 MCP 服务器。',
|
||||
'mcp.installed.toolSingular': '{count} 工具',
|
||||
'mcp.installed.toolPlural': '{count} 工具',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
@@ -927,6 +927,12 @@ const en: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': '{count} tool',
|
||||
'mcp.installed.toolPlural': '{count} tools',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
'mcp.installed.search.clearAria': 'Clear filter',
|
||||
'mcp.installed.search.countMatches': '{shown} of {total} servers',
|
||||
'mcp.installed.search.noMatches': 'No servers match "{query}".',
|
||||
'mcp.inventory.openButton': 'Inventory',
|
||||
'mcp.inventory.openAria': 'Open the sharable MCP inventory panel',
|
||||
'mcp.inventory.title': 'Sharable MCP Inventory',
|
||||
|
||||
Reference in New Issue
Block a user