From 13d70bdac926eda0f9070f4ea5f4936cb23d9034 Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Mon, 25 May 2026 19:05:17 +0500 Subject: [PATCH 1/2] feat(mcp): search + keyboard navigation for installed MCP servers --- .../channels/mcp/InstalledServerList.test.tsx | 283 ++++++++++++++++++ .../channels/mcp/InstalledServerList.tsx | 149 ++++++--- .../channels/mcp/McpServerSearch.test.tsx | 67 +++++ .../channels/mcp/McpServerSearch.tsx | 57 ++++ .../components/channels/mcp/McpServersTab.tsx | 12 +- app/src/lib/i18n/chunks/ar-1.ts | 6 + app/src/lib/i18n/chunks/bn-1.ts | 6 + app/src/lib/i18n/chunks/de-1.ts | 6 + app/src/lib/i18n/chunks/en-1.ts | 6 + app/src/lib/i18n/chunks/es-1.ts | 6 + app/src/lib/i18n/chunks/fr-1.ts | 6 + app/src/lib/i18n/chunks/hi-1.ts | 6 + app/src/lib/i18n/chunks/id-1.ts | 6 + app/src/lib/i18n/chunks/it-1.ts | 6 + app/src/lib/i18n/chunks/ko-1.ts | 6 + app/src/lib/i18n/chunks/pt-1.ts | 6 + app/src/lib/i18n/chunks/ru-1.ts | 6 + app/src/lib/i18n/chunks/zh-CN-1.ts | 6 + app/src/lib/i18n/en.ts | 6 + 19 files changed, 612 insertions(+), 40 deletions(-) create mode 100644 app/src/components/channels/mcp/McpServerSearch.test.tsx create mode 100644 app/src/components/channels/mcp/McpServerSearch.tsx diff --git a/app/src/components/channels/mcp/InstalledServerList.test.tsx b/app/src/components/channels/mcp/InstalledServerList.test.tsx index 5f2b653143..bfa98789f3 100644 --- a/app/src/components/channels/mcp/InstalledServerList.test.tsx +++ b/app/src/components/channels/mcp/InstalledServerList.test.tsx @@ -234,4 +234,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( + {}} + onBrowseCatalog={() => {}} + filter="" + /> + ); + expect(screen.getByText('File Server')).toBeInTheDocument(); + expect(screen.getByText('DB Server')).toBeInTheDocument(); + }); + + it('filters by display_name case-insensitively', () => { + render( + {}} + onBrowseCatalog={() => {}} + filter="FILE" + /> + ); + expect(screen.getByText('File Server')).toBeInTheDocument(); + expect(screen.queryByText('DB Server')).not.toBeInTheDocument(); + }); + + it('filters by qualified_name', () => { + render( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + onBrowseCatalog={() => {}} + filter="" + /> + ); + expect(screen.queryByText(/of \d+ servers/)).not.toBeInTheDocument(); + }); + + it('hides the count when filter is only whitespace', () => { + render( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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(); + }); }); diff --git a/app/src/components/channels/mcp/InstalledServerList.tsx b/app/src/components/channels/mcp/InstalledServerList.tsx index 57d52e164e..139e5cb336 100644 --- a/app/src/components/channels/mcp/InstalledServerList.tsx +++ b/app/src/components/channels/mcp/InstalledServerList.tsx @@ -1,6 +1,17 @@ /** * 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 ` ) : ( -
    - {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 && ( +

    + {t('mcp.installed.search.countMatches') + .replace('{shown}', String(filteredServers.length)) + .replace('{total}', String(servers.length))} +

    + )} + {filteredServers.length === 0 ? ( +
    +

    + {t('mcp.installed.search.noMatches').replace('{query}', filter.trim())} +

    +
    + ) : ( +
      + {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 ( -
    • - -
    • - ); - })} -
    + + + ); + })} +
+ )} + )} ); diff --git a/app/src/components/channels/mcp/McpServerSearch.test.tsx b/app/src/components/channels/mcp/McpServerSearch.test.tsx new file mode 100644 index 0000000000..96d257dc9d --- /dev/null +++ b/app/src/components/channels/mcp/McpServerSearch.test.tsx @@ -0,0 +1,67 @@ +/** + * 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( {}} />); + 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( {}} />); + 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( {}} />); + expect(screen.queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument(); + }); + + it('renders the clear button when value is non-empty', () => { + render( {}} />); + expect(screen.getByRole('button', { name: 'Clear filter' })).toBeInTheDocument(); + }); + + it('reflects the current value as the input value', () => { + render( {}} />); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: 'Clear filter' })); + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('renders the clear icon as decorative (aria-hidden)', () => { + const { container } = render( {}} />); + // 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'); + }); +}); diff --git a/app/src/components/channels/mcp/McpServerSearch.tsx b/app/src/components/channels/mcp/McpServerSearch.tsx new file mode 100644 index 0000000000..13e98a15fd --- /dev/null +++ b/app/src/components/channels/mcp/McpServerSearch.tsx @@ -0,0 +1,57 @@ +/** + * 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 ( +
+ 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 && ( + + )} +
+ ); +}; + +export default McpServerSearch; diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index 13e9c2868c..62e3f73a3d 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -13,6 +13,7 @@ import InstallDialog from './InstallDialog'; import InstalledServerDetail from './InstalledServerDetail'; import InstalledServerList from './InstalledServerList'; import McpCatalogBrowser from './McpCatalogBrowser'; +import McpServerSearch from './McpServerSearch'; import type { ConnStatus, InstalledServer } from './types'; const log = debug('mcp-clients:tab'); @@ -31,6 +32,9 @@ const McpServersTab = () => { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [rightPane, setRightPane] = useState({ 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(''); const pollTimerRef = useRef | null>(null); const loadInstalled = useCallback(async () => { @@ -154,19 +158,25 @@ const McpServersTab = () => { {t('mcp.alphaBannerText')}
- {/* Left pane: installed list */} + {/* Left pane: search + installed list */}
{loadError && (
{loadError}
)} + {servers.length > 0 && ( +
+ +
+ )}
diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index e3bcdd91d9..186432927d 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -1182,6 +1182,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.tab.loading': 'جارٍ تحميل خوادم MCP...', 'mcp.tab.emptyDetail': 'حدد خادمًا أو تصفح الكتالوج.', 'mcp.install.loadingDetail': 'جارٍ تحميل تفاصيل الخادم...', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 888413d1fc..87312ea295 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -1192,6 +1192,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.tab.loading': 'MCP সার্ভার লোড হচ্ছে...', 'mcp.tab.emptyDetail': 'একটি সার্ভার বা সারি নির্বাচন করুন।', 'mcp.install.loadingDetail': 'সার্ভারের বিবরণ লোড হচ্ছে...', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index 9d275ba803..cbf1779c97 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -1210,6 +1210,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.tab.loading': 'MCP-Server werden geladen...', 'mcp.tab.emptyDetail': 'Wählen Sie einen Server aus oder durchsuchen Sie den Katalog.', 'mcp.install.loadingDetail': 'Serverdetails werden geladen...', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 8218a57e08..b8cca91bd0 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -1155,6 +1155,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.tab.loading': 'Loading MCP servers...', 'mcp.tab.emptyDetail': 'Select a server or browse the catalog.', 'mcp.install.loadingDetail': 'Loading server details...', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index d4a6a4669d..c8ffccd5e1 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -1207,6 +1207,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.tab.loading': 'Cargando servidores MCP...', 'mcp.tab.emptyDetail': 'Seleccione un servidor o explore el catálogo.', 'mcp.install.loadingDetail': 'Cargando detalles del servidor...', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index de1fab9b63..4f0f28c936 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -1212,6 +1212,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.tab.loading': 'Chargement des serveurs MCP...', 'mcp.tab.emptyDetail': 'Sélectionnez un serveur ou parcourez le catalogue.', 'mcp.install.loadingDetail': 'Chargement des détails du serveur...', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index 85cc03c60a..b60e1ddf4f 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -1190,6 +1190,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.tab.loading': 'MCP सर्वर लोड हो रहा है...', 'mcp.tab.emptyDetail': 'एक सर्वर चुनें या कैटलॉग ब्राउज़ करें।', 'mcp.install.loadingDetail': 'सर्वर विवरण लोड हो रहा है...', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 95d0e65bb3..e0b9f990cf 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -1195,6 +1195,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.tab.loading': 'Memuat MCP server...', 'mcp.tab.emptyDetail': 'Pilih server atau telusuri katalog.', 'mcp.install.loadingDetail': 'Memuat detail server...', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 3799e6873e..9efda2558f 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -1200,6 +1200,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.tab.loading': 'Caricamento MCP server...', 'mcp.tab.emptyDetail': 'Selezionare un server o sfogliare il catalogo.', 'mcp.install.loadingDetail': 'Caricamento dettagli server...', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 094cff2679..9b4eb83419 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -1192,6 +1192,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.tab.loading': 'MCP 서버 로드 중...', 'mcp.tab.emptyDetail': '서버를 선택하거나 카탈로그를 찾아보세요.', 'mcp.install.loadingDetail': '서버 세부정보 로드 중...', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 2a41a607ca..13dc61e24c 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -1205,6 +1205,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.tab.loading': 'Carregando servidores MCP...', 'mcp.tab.emptyDetail': 'Selecione um servidor ou navegue no catálogo.', 'mcp.install.loadingDetail': 'Carregando detalhes do servidor...', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index 3790b2484e..801c0d5d6d 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -1195,6 +1195,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.tab.loading': 'Загрузка серверов MCP...', 'mcp.tab.emptyDetail': 'Выберите сервер или просмотрите каталог.', 'mcp.install.loadingDetail': 'Загрузка сведений о сервере...', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 78932def3c..fca41874d9 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -1175,6 +1175,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.tab.loading': '正在加载 MCP 服务器...', 'mcp.tab.emptyDetail': '选择服务器或浏览目录。', 'mcp.install.loadingDetail': '正在加载服务器详细信息...', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a8fed1723b..8efe7ffef4 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -772,6 +772,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.tab.loading': 'Loading MCP servers...', 'mcp.tab.emptyDetail': 'Select a server or browse the catalog.', 'mcp.install.loadingDetail': 'Loading server details...', From 2cd8d189b7f48fd46a0bbbe42a4c9ed19a302dcc Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Tue, 26 May 2026 05:15:36 +0500 Subject: [PATCH 2/2] fix(mcp/list): i18n the status-dot title via channels.status.* keys, prettier on PR files --- .../channels/mcp/InstalledServerList.test.tsx | 7 +++--- .../channels/mcp/InstalledServerList.tsx | 25 +++++++++++-------- .../channels/mcp/McpServerSearch.test.tsx | 8 ++---- .../channels/mcp/McpServerSearch.tsx | 5 +--- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/src/components/channels/mcp/InstalledServerList.test.tsx b/app/src/components/channels/mcp/InstalledServerList.test.tsx index bfa98789f3..5180bd70ab 100644 --- a/app/src/components/channels/mcp/InstalledServerList.test.tsx +++ b/app/src/components/channels/mcp/InstalledServerList.test.tsx @@ -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(); }); // ----------------------------------------------------------------------- diff --git a/app/src/components/channels/mcp/InstalledServerList.tsx b/app/src/components/channels/mcp/InstalledServerList.tsx index 139e5cb336..ed6aca55c0 100644 --- a/app/src/components/channels/mcp/InstalledServerList.tsx +++ b/app/src/components/channels/mcp/InstalledServerList.tsx @@ -9,8 +9,7 @@ * server buttons (clamped at the edges); Enter/Space activate via * the underlying `