mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
M3gA-Mind
parent
7bf18562a1
commit
07140e9c0b
@@ -6,14 +6,16 @@
|
||||
* between "All", "Installed", and "Registry" views.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
||||
import { openUrl } from '../../../utils/openUrl';
|
||||
import ChipTabs from '../../layout/ChipTabs';
|
||||
import Button from '../../ui/Button';
|
||||
import InstallDialog from './InstallDialog';
|
||||
import InstalledServerDetail from './InstalledServerDetail';
|
||||
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
|
||||
import McpInventoryPanel from './McpInventoryPanel';
|
||||
import { deriveAuthor } from './McpServerCard';
|
||||
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
|
||||
@@ -73,56 +75,181 @@ const STATUS_DOT: Record<ServerStatus, string> = {
|
||||
disabled: 'bg-surface-strong',
|
||||
};
|
||||
|
||||
// Maps an upstream registry id to its i18n label key. The official
|
||||
// modelcontextprotocol.io registry is highlighted (sage) over Smithery so the
|
||||
// authoritative source reads as the "top" one.
|
||||
const SOURCE_LABEL_KEY: Record<string, string> = {
|
||||
mcp_official: 'mcp.tab.source.official',
|
||||
smithery: 'mcp.tab.source.smithery',
|
||||
};
|
||||
|
||||
/** Pill attributing a catalog row to the registry it came from. */
|
||||
const SourceBadge = ({ source }: { source?: string }) => {
|
||||
const { t } = useT();
|
||||
const labelKey = source ? SOURCE_LABEL_KEY[source] : undefined;
|
||||
if (!labelKey) {
|
||||
return <span className="text-xs text-stone-400 dark:text-neutral-600">—</span>;
|
||||
}
|
||||
const isOfficial = source === 'mcp_official';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
isOfficial
|
||||
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/15 dark:text-sage-300'
|
||||
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}`}>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
/** Transport classification a catalog row can be filtered by. */
|
||||
type Transport = 'hosted' | 'stdio';
|
||||
|
||||
/**
|
||||
* Tiny hint on how a catalog row is reached: a hosted server (has an HTTP
|
||||
* endpoint) can offer browser sign-in; a local server is run on-device and
|
||||
* configured with a pasted token. This mirrors the `is_deployed` sort that
|
||||
* floats hosted servers to the top.
|
||||
* Classify a catalog row by how it runs: `hosted` = reachable over an HTTP
|
||||
* endpoint (cloud-run); `stdio` = installed and run on-device as a subprocess.
|
||||
* `is_deployed` is set by the registry adapter when the server exposes a remote.
|
||||
*/
|
||||
const TransportHintBadge = ({ deployed }: { deployed?: boolean }) => {
|
||||
const transportOf = (server: SmitheryServer): Transport =>
|
||||
server.is_deployed ? 'hosted' : 'stdio';
|
||||
|
||||
/**
|
||||
* Derive a browsable source-repository URL from the registry slug. The official
|
||||
* registry namespaces community servers as `io.github.<user>/<repo>` (and
|
||||
* `io.gitlab.<user>/…`), which maps 1:1 to a repo page. Returns `null` for
|
||||
* vendor reverse-DNS slugs that don't encode a code host.
|
||||
*/
|
||||
export const deriveRepoUrl = (qualifiedName: string): string | null => {
|
||||
const slash = qualifiedName.indexOf('/');
|
||||
if (slash < 1) return null;
|
||||
const prefix = qualifiedName.slice(0, slash);
|
||||
const repo = qualifiedName.slice(slash + 1);
|
||||
if (!repo) return null;
|
||||
if (prefix.startsWith('io.github.')) {
|
||||
return `https://github.com/${prefix.slice('io.github.'.length)}/${repo}`;
|
||||
}
|
||||
if (prefix.startsWith('io.gitlab.')) {
|
||||
return `https://gitlab.com/${prefix.slice('io.gitlab.'.length)}/${repo}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Transport pill (Stdio vs Hosted) — the catalog's primary classification. */
|
||||
const TransportBadge = ({ transport }: { transport: Transport }) => {
|
||||
const { t } = useT();
|
||||
const hosted = !!deployed;
|
||||
const hosted = transport === 'hosted';
|
||||
return (
|
||||
<span
|
||||
title={t(hosted ? 'mcp.tab.transport.hostedHint' : 'mcp.tab.transport.localHint')}
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
hosted
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300'
|
||||
: 'bg-stone-100 text-stone-500 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
: 'bg-surface-strong text-content-muted'
|
||||
}`}>
|
||||
{t(hosted ? 'mcp.tab.transport.hosted' : 'mcp.tab.transport.local')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* External link that opens in the system browser. Stops propagation so clicking
|
||||
* a server's website/repo never also triggers the row's install action.
|
||||
*/
|
||||
const ExternalLink = ({ href, label }: { href: string; label: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void openUrl(href).catch(() => {});
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{label}
|
||||
<svg
|
||||
className="w-2.5 h-2.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/**
|
||||
* One catalog (registry) row. Memoized so the 5s status poll — which re-renders
|
||||
* the parent tab — doesn't re-render the (potentially large) catalog list: the
|
||||
* catalog data and the install handler are stable across polls, so memo skips
|
||||
* every row. This is the main lever against the "rendering is slow" report.
|
||||
*/
|
||||
const CatalogRow = memo(
|
||||
({
|
||||
server,
|
||||
onInstall,
|
||||
}: {
|
||||
server: SmitheryServer;
|
||||
onInstall: (qualifiedName: string) => void;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
const repoUrl = deriveRepoUrl(server.qualified_name);
|
||||
const author = deriveAuthor(server.qualified_name);
|
||||
return (
|
||||
<tr
|
||||
className="hover:bg-surface-muted dark:hover:bg-surface-muted/40 cursor-pointer transition-colors"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
|
||||
onClick={() => onInstall(server.qualified_name)}
|
||||
onKeyDown={e => {
|
||||
// Only act on keys aimed at the row itself — Enter/Space bubble up
|
||||
// from the nested Website/Repository buttons, which must not open
|
||||
// the install flow.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onInstall(server.qualified_name);
|
||||
}
|
||||
}}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{server.icon_url ? (
|
||||
<img
|
||||
src={server.icon_url}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
|
||||
🔌
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-content truncate">{server.display_name}</span>
|
||||
{server.official && (
|
||||
<span
|
||||
title={t('mcp.tab.officialHint')}
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
||||
✓ {t('mcp.tab.officialBadge')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* The registry is full of look-alike names (a dozen "gmail"
|
||||
servers); the slug is the unique identifier that tells them
|
||||
apart. */}
|
||||
<span className="text-[11px] font-mono text-content-faint truncate block">
|
||||
{server.qualified_name}
|
||||
</span>
|
||||
{server.description && (
|
||||
<span className="text-xs text-content-faint line-clamp-3 block">
|
||||
{server.description}
|
||||
</span>
|
||||
)}
|
||||
{(server.website_url || repoUrl) && (
|
||||
<span className="flex items-center gap-3 mt-1">
|
||||
{server.website_url && (
|
||||
<ExternalLink href={server.website_url} label={t('mcp.tab.link.website')} />
|
||||
)}
|
||||
{repoUrl && <ExternalLink href={repoUrl} label={t('mcp.tab.link.repo')} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<TransportBadge transport={transportOf(server)} />
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-xs text-content-muted truncate block">{author ?? '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
|
||||
{t('mcp.install.button')}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
CatalogRow.displayName = 'CatalogRow';
|
||||
|
||||
const McpServersTab = () => {
|
||||
const { t } = useT();
|
||||
const [servers, setServers] = useState<InstalledServer[]>([]);
|
||||
@@ -135,12 +262,17 @@ const McpServersTab = () => {
|
||||
// Unified search + filter
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeChip, setActiveChip] = useState<FilterChip>('all');
|
||||
// Secondary classification filter over catalog rows: by transport.
|
||||
const [transportFilter, setTransportFilter] = useState<'all' | Transport>('all');
|
||||
|
||||
// Registry catalog results
|
||||
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [catalogPage, setCatalogPage] = useState(1);
|
||||
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
|
||||
// Set when a registry fetch fails so the Registry view shows an error state
|
||||
// (with retry) instead of silently falling back to an empty/stale catalog.
|
||||
const [catalogError, setCatalogError] = useState(false);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -167,42 +299,54 @@ const McpServersTab = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCatalog = useCallback(async (query: string, page: number, append: boolean) => {
|
||||
const seq = ++requestSeqRef.current;
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const result = await mcpClientsApi.registrySearch({
|
||||
query: query || undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
const incoming = result.servers ?? [];
|
||||
setCatalogServers(prev => dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming));
|
||||
setCatalogPage(result.page);
|
||||
setCatalogTotalPages(result.total_pages);
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
log('catalog fetch error: %o', err);
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const fetchCatalog = useCallback(
|
||||
async (query: string, transport: 'all' | Transport, page: number, append: boolean) => {
|
||||
const seq = ++requestSeqRef.current;
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const result = await mcpClientsApi.registrySearch({
|
||||
query: query || undefined,
|
||||
transport: transport === 'all' ? undefined : transport,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
const incoming = result.servers ?? [];
|
||||
setCatalogServers(prev =>
|
||||
dedupeByQualifiedName(append ? [...prev, ...incoming] : incoming)
|
||||
);
|
||||
setCatalogPage(result.page);
|
||||
setCatalogTotalPages(result.total_pages);
|
||||
setCatalogError(false);
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
log('catalog fetch error: %o', err);
|
||||
// A fresh (non-append) fetch that fails leaves no usable rows — surface
|
||||
// the error. A failed "load more" keeps the rows already shown.
|
||||
if (!append) setCatalogError(true);
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setCatalogLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
|
||||
}, [loadInstalled, fetchStatuses]);
|
||||
|
||||
// Fetch catalog on mount and when search changes
|
||||
// Fetch catalog (page 1) on mount and whenever the query or transport filter
|
||||
// changes. Search + transport now run in the core over the cached full
|
||||
// catalog, so changing either re-queries from the top.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void fetchCatalog(searchQuery, 1, false);
|
||||
void fetchCatalog(searchQuery, transportFilter, 1, false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchQuery, fetchCatalog]);
|
||||
}, [searchQuery, transportFilter, fetchCatalog]);
|
||||
|
||||
// Poll status
|
||||
useEffect(() => {
|
||||
@@ -274,9 +418,37 @@ const McpServersTab = () => {
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
void fetchCatalog(searchQuery, catalogPage + 1, true);
|
||||
void fetchCatalog(searchQuery, transportFilter, catalogPage + 1, true);
|
||||
};
|
||||
|
||||
// Bulk lifecycle actions for the health toolbar. One failure doesn't abort the
|
||||
// batch (allSettled), and we always refresh status so the dots reflect reality
|
||||
// — but if any call rejected we then throw so the toolbar can surface the
|
||||
// failure (otherwise a partial/total failure would look like success).
|
||||
const handleReconnectAll = useCallback(
|
||||
async (serverIds: string[]) => {
|
||||
log('reconnect all: %o', serverIds);
|
||||
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id)));
|
||||
await fetchStatuses();
|
||||
if (results.some(r => r.status === 'rejected')) {
|
||||
throw new Error(t('mcp.health.opErrorGeneric'));
|
||||
}
|
||||
},
|
||||
[fetchStatuses, t]
|
||||
);
|
||||
|
||||
const handleDisconnectAll = useCallback(
|
||||
async (serverIds: string[]) => {
|
||||
log('disconnect all: %o', serverIds);
|
||||
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id)));
|
||||
await fetchStatuses();
|
||||
if (results.some(r => r.status === 'rejected')) {
|
||||
throw new Error(t('mcp.health.opErrorGeneric'));
|
||||
}
|
||||
},
|
||||
[fetchStatuses, t]
|
||||
);
|
||||
|
||||
const selectedServer =
|
||||
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
|
||||
const selectedConnStatus =
|
||||
@@ -286,95 +458,43 @@ const McpServersTab = () => {
|
||||
// legacy double-installs can still exist on disk; collapse them by
|
||||
// qualified_name (servers arrive earliest-first) so the list stays "one per
|
||||
// thing". Raw `servers` is kept for server_id-keyed detail/status lookups.
|
||||
const installedView = dedupeInstalledByQualifiedName(servers);
|
||||
|
||||
// Filter installed servers by search
|
||||
const filteredInstalled = installedView.filter(s => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.qualified_name.toLowerCase().includes(q) ||
|
||||
(s.description ?? '').toLowerCase().includes(q)
|
||||
// Memoized so the 5s status poll doesn't rebuild + refilter the list.
|
||||
const filteredInstalled = useMemo(() => {
|
||||
const view = dedupeInstalledByQualifiedName(servers);
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return view;
|
||||
return view.filter(
|
||||
s =>
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.qualified_name.toLowerCase().includes(q) ||
|
||||
(s.description ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [servers, searchQuery]);
|
||||
|
||||
// Catalog rows minus already-installed servers. Search + transport filtering
|
||||
// now happen in the core over the cached full catalog (so relevance and
|
||||
// pagination are accurate); the only thing left to do client-side is hide
|
||||
// servers the user already installed. Memoized so the status poll doesn't
|
||||
// recompute it.
|
||||
const availableCatalog = useMemo(() => {
|
||||
const installedNames = new Set(servers.map(s => s.qualified_name));
|
||||
return catalogServers.filter(s => !installedNames.has(s.qualified_name));
|
||||
}, [catalogServers, servers]);
|
||||
|
||||
// Catalog rows (minus already-installed), in the registry's relevance order.
|
||||
// We deliberately do NOT split by auth method here: `is_deployed` only tells
|
||||
// us a server has a hosted endpoint, not whether it signs in via the browser
|
||||
// or wants a pasted token — that's only known once the install dialog fetches
|
||||
// the detail (`required_env_keys`) or the connect attempt hits an OAuth
|
||||
// challenge. Each row carries a Hosted/Local hint badge instead; the real
|
||||
// auth requirement surfaces in the install flow.
|
||||
const installedNames = new Set(servers.map(s => s.qualified_name));
|
||||
const availableCatalog = catalogServers.filter(s => !installedNames.has(s.qualified_name));
|
||||
const showRegistry = activeChip === 'all' || activeChip === 'registry';
|
||||
|
||||
const renderCatalogRow = (server: SmitheryServer) => (
|
||||
<tr
|
||||
key={`catalog-${server.qualified_name}`}
|
||||
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('mcp.tab.aria.installServer').replace('{name}', server.display_name)}
|
||||
onClick={() => handleSelectInstall(server.qualified_name)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelectInstall(server.qualified_name);
|
||||
}
|
||||
}}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{server.icon_url ? (
|
||||
<img src={server.icon_url} alt="" className="w-5 h-5 rounded shrink-0 object-contain" />
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
|
||||
🔌
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
|
||||
{server.display_name}
|
||||
</span>
|
||||
{server.official && (
|
||||
<span
|
||||
title={t('mcp.tab.officialHint')}
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-sage-100 px-1.5 py-0.5 text-[10px] font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
||||
✓ {t('mcp.tab.officialBadge')}
|
||||
</span>
|
||||
)}
|
||||
<TransportHintBadge deployed={server.is_deployed} />
|
||||
</span>
|
||||
{/* The registry is full of look-alike names (a dozen "gmail"
|
||||
servers); the slug is the unique identifier that tells them
|
||||
apart. */}
|
||||
<span className="text-[11px] font-mono text-stone-400 dark:text-neutral-500 truncate block">
|
||||
{server.qualified_name}
|
||||
</span>
|
||||
{server.description && (
|
||||
<span className="text-xs text-stone-400 dark:text-neutral-500 line-clamp-3 block">
|
||||
{server.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<SourceBadge source={server.source} />
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400 truncate block">
|
||||
{deriveAuthor(server.qualified_name) ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
|
||||
{t('mcp.install.button')}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
// Render catalog rows from memoized data so they survive parent re-renders
|
||||
// (the status poll) untouched — `CatalogRow` is itself memoized.
|
||||
const catalogRows = useMemo(
|
||||
() =>
|
||||
availableCatalog.map(server => (
|
||||
<CatalogRow
|
||||
key={`catalog-${server.qualified_name}`}
|
||||
server={server}
|
||||
onInstall={handleSelectInstall}
|
||||
/>
|
||||
)),
|
||||
[availableCatalog, handleSelectInstall]
|
||||
);
|
||||
|
||||
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
|
||||
@@ -415,26 +535,11 @@ const McpServersTab = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Install view
|
||||
// Install view — InstallDialog renders its own "← Go back", so the tab does
|
||||
// NOT add a second one here (that was the duplicate back button).
|
||||
if (view.mode === 'install') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
onClick={() => setView({ mode: 'home' })}
|
||||
leadingIcon={
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
}>
|
||||
{t('mcp.install.back')}
|
||||
</Button>
|
||||
<InstallDialog
|
||||
qualifiedName={view.qualifiedName}
|
||||
prefillEnv={view.prefillEnv}
|
||||
@@ -468,23 +573,59 @@ const McpServersTab = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<ChipTabs<FilterChip>
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
value={activeChip}
|
||||
onChange={setActiveChip}
|
||||
items={[
|
||||
{ id: 'all', label: t('mcp.tab.filter.all') },
|
||||
{
|
||||
id: 'installed',
|
||||
label: t('mcp.tab.filter.installed').replace(
|
||||
'{count}',
|
||||
String(filteredInstalled.length)
|
||||
),
|
||||
},
|
||||
{ id: 'registry', label: t('mcp.tab.filter.registry') },
|
||||
]}
|
||||
/>
|
||||
{/* Filters on one bar: the scope (All / Installed / Registry) chips, then —
|
||||
when registry rows are visible — a labelled transport filter rendered
|
||||
as Stdio/Hosted TOGGLES (no second "All" chip; deselecting both means
|
||||
all). This avoids the duplicate-"All" confusion. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ChipTabs<FilterChip>
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
value={activeChip}
|
||||
onChange={setActiveChip}
|
||||
items={[
|
||||
{ id: 'all', label: t('mcp.tab.filter.all') },
|
||||
{
|
||||
id: 'installed',
|
||||
label: t('mcp.tab.filter.installed').replace(
|
||||
'{count}',
|
||||
String(filteredInstalled.length)
|
||||
),
|
||||
},
|
||||
{ id: 'registry', label: t('mcp.tab.filter.registry') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{showRegistry && (
|
||||
<>
|
||||
<span className="hidden sm:block h-5 w-px bg-line-subtle" aria-hidden="true" />
|
||||
<span className="text-xs font-medium text-content-muted">
|
||||
{t('mcp.tab.transportFilter.label')}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="group"
|
||||
aria-label={t('mcp.tab.transportFilter.aria')}>
|
||||
{(['stdio', 'hosted'] as const).map(tp => {
|
||||
const active = transportFilter === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setTransportFilter(prev => (prev === tp ? 'all' : tp))}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-content text-surface'
|
||||
: 'border border-line text-content-muted hover:bg-surface-muted'
|
||||
}`}>
|
||||
{t(tp === 'stdio' ? 'mcp.tab.transport.local' : 'mcp.tab.transport.hosted')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="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">
|
||||
@@ -492,6 +633,18 @@ const McpServersTab = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection health + bulk lifecycle actions. Only meaningful once
|
||||
servers are installed; surfaces "Retry all" for error-state servers
|
||||
(the failed-connection retry affordance #4272 asks for) and
|
||||
"Disconnect all". Reads the polled statuses — no extra fetches. */}
|
||||
{(activeChip === 'all' || activeChip === 'installed') && statuses.length > 0 && (
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={statuses}
|
||||
onReconnect={handleReconnectAll}
|
||||
onDisconnect={handleDisconnectAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table — horizontally scrollable so the Source/Author/Action columns
|
||||
aren't clipped when the panel is narrower than the table's natural
|
||||
width (the wrapper was `overflow-hidden`, which cut them off with no
|
||||
@@ -505,7 +658,7 @@ const McpServersTab = () => {
|
||||
{t('mcp.tab.column.name')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-28">
|
||||
{t('mcp.tab.column.source')}
|
||||
{t('mcp.tab.column.type')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-content-muted hidden sm:table-cell w-36">
|
||||
{t('mcp.tab.column.author')}
|
||||
@@ -573,12 +726,30 @@ const McpServersTab = () => {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Registry servers — one relevance-ordered list. Each row shows a
|
||||
Hosted/Local hint; the real auth method appears on install. */}
|
||||
{showRegistry && availableCatalog.map(renderCatalogRow)}
|
||||
{/* Registry servers — official first, then the registry's relevance
|
||||
order. Each row shows its transport, website/repo links, and the
|
||||
real auth surfaces on install. */}
|
||||
{showRegistry && catalogRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Registry fetch error — takes precedence over the empty state so a
|
||||
failed load reads as an error (with retry), not "no results". */}
|
||||
{showRegistry && catalogError && !catalogLoading && (
|
||||
<div
|
||||
data-testid="mcp-catalog-error"
|
||||
className="py-8 text-center text-sm text-coral-700 dark:text-coral-300 space-y-2">
|
||||
<p>{t('mcp.catalog.loadFailed')}</p>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
onClick={() => void fetchCatalog(searchQuery, transportFilter, 1, false)}
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty states */}
|
||||
{activeChip === 'installed' && filteredInstalled.length === 0 && (
|
||||
<div
|
||||
@@ -587,19 +758,23 @@ const McpServersTab = () => {
|
||||
{t('mcp.installed.empty')}
|
||||
</div>
|
||||
)}
|
||||
{activeChip === 'registry' && availableCatalog.length === 0 && !catalogLoading && (
|
||||
<div
|
||||
data-testid="mcp-catalog-empty"
|
||||
className="py-8 text-center text-sm text-content-faint">
|
||||
{searchQuery
|
||||
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
||||
: t('mcp.catalog.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{activeChip === 'registry' &&
|
||||
availableCatalog.length === 0 &&
|
||||
!catalogLoading &&
|
||||
!catalogError && (
|
||||
<div
|
||||
data-testid="mcp-catalog-empty"
|
||||
className="py-8 text-center text-sm text-content-faint">
|
||||
{searchQuery
|
||||
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
||||
: t('mcp.catalog.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{activeChip === 'all' &&
|
||||
filteredInstalled.length === 0 &&
|
||||
availableCatalog.length === 0 &&
|
||||
!catalogLoading && (
|
||||
!catalogLoading &&
|
||||
!catalogError && (
|
||||
<div
|
||||
data-testid="mcp-catalog-empty"
|
||||
className="py-8 text-center text-sm text-content-faint">
|
||||
|
||||
Reference in New Issue
Block a user