Skip to content
Open
33 changes: 30 additions & 3 deletions apps/mesh/src/shared/utils/group-connections.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import type { ConnectionEntity } from "@decocms/mesh-sdk";
import { getConnectionSlug } from "./connection-slug";

/**
* Strip auto-generated instance suffixes like "(2)" or "(a1b2)" from a title.
* Matches 1-6 character parenthesized suffixes at the end of the string.
*/
const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/;

/**
* Returns the canonical display title for a connection.
* Checks metadata.displayName first (set at install time, never changes),
* then falls back to stripping instance suffixes from the title.
*/
export function getConnectionDisplayTitle(
connection: ConnectionEntity,
): string {
const metadata = connection.metadata as Record<string, unknown> | null;
if (metadata?.displayName && typeof metadata.displayName === "string") {
return metadata.displayName;
}
return connection.title.replace(INSTANCE_SUFFIX_RE, "");
}

export interface ConnectionGroup {
type: "group";
key: string;
Expand All @@ -16,8 +37,14 @@ export interface SingleConnection {

export type GroupedItem = SingleConnection | ConnectionGroup;

/**
* Groups connections by their slug (app_name or derived from URL/title).
* Accepts an optional registry title lookup so group/single cards show
* the canonical registry name instead of the (possibly renamed) instance title.
*/
export function groupConnections(
connections: ConnectionEntity[],
registryTitles?: Map<string, string>,
): GroupedItem[] {
const buckets = new Map<string, ConnectionEntity[]>();
for (const c of connections) {
Expand All @@ -40,16 +67,16 @@ export function groupConnections(

const bucket = buckets.get(key)!;
const first = bucket[0]!;
const registryTitle = first.app_name && registryTitles?.get(first.app_name);

if (bucket.length === 1) {
items.push({ type: "single", connection: first });
} else {
items.push({
type: "group",
key,
icon: first.icon,
title: first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title,
title: registryTitle || getConnectionDisplayTitle(first),
connections: bucket,
});
}
Expand Down
35 changes: 28 additions & 7 deletions apps/mesh/src/web/components/chat/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { cn } from "@deco/ui/lib/utils.ts";
import {
getWellKnownDecopilotVirtualMCP,
isDecopilot,
useConnections,
useProjectContext,
} from "@decocms/mesh-sdk";

import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent";
import {
ArrowUp,
Expand Down Expand Up @@ -56,6 +58,26 @@ import { question004Sound } from "@deco/ui/lib/question-004.ts";
import { AddConnectionDialog } from "@/web/views/virtual-mcp/add-connection-dialog";
import { ConnectionsBanner } from "./connections-banner";

function HomeConnectionsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const existingConnections = useConnections();
const existingConnectionIds = new Set(existingConnections.map((c) => c.id));
return (
<AddConnectionDialog
open={open}
onOpenChange={onOpenChange}
addedConnectionIds={existingConnectionIds}
onAdd={() => onOpenChange(false)}
defaultTab="all"
/>
);
}

// ============================================================================
// VirtualMCPBadge - Internal component for displaying selected virtual MCP
// ============================================================================
Expand Down Expand Up @@ -648,13 +670,12 @@ export function ChatInput({
</div>
</div>

<AddConnectionDialog
open={connectionsOpen}
onOpenChange={setConnectionsOpen}
addedConnectionIds={new Set()}
onAdd={() => {}}
defaultTab="all"
/>
{showConnectionsBanner && (
<HomeConnectionsDialog
open={connectionsOpen}
onOpenChange={setConnectionsOpen}
/>
)}
</>
);
}
19 changes: 5 additions & 14 deletions apps/mesh/src/web/components/details/connection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generatePrefixedId } from "@/shared/utils/generate-id";
import { getConnectionDisplayTitle } from "@/shared/utils/group-connections";
import { EmptyState } from "@/web/components/empty-state.tsx";
import { ErrorBoundary } from "@/web/components/error-boundary";
import { recordToEnvVars } from "@/web/components/env-vars-editor";
Expand Down Expand Up @@ -424,12 +425,7 @@ function ConnectionInspectorViewWithConnection({
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{(() => {
const first = siblings[0] ?? connection;
return first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title;
})()}
{getConnectionDisplayTitle(siblings[0] ?? connection)}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
Expand Down Expand Up @@ -562,12 +558,7 @@ function ConnectionInspectorViewWithConnection({
<div className="flex flex-col h-full overflow-hidden">
<ConnectionDetailHeader
connection={connection}
displayTitle={(() => {
const first = siblings[0] ?? connection;
return first.app_name
? first.title.replace(/\s*\(\d+\)\s*$/, "")
: first.title;
})()}
displayTitle={getConnectionDisplayTitle(siblings[0] ?? connection)}
/>
<div className="flex-1 overflow-auto @container">
<div className="grid grid-cols-1 @3xl:grid-cols-2 gap-5 p-6">
Expand All @@ -585,7 +576,7 @@ function ConnectionInspectorViewWithConnection({
setIsAddingInstance(true);
try {
const base = siblings[0] ?? connection;
const baseName = base.title.replace(/\s*\(\d+\)\s*$/, "");
const baseName = getConnectionDisplayTitle(base);
const nextNumber = siblings.length + 1;
const newTitle = `${baseName} (${nextNumber})`;
const newId = generatePrefixedId("conn");
Expand All @@ -606,7 +597,7 @@ function ConnectionInspectorViewWithConnection({
connection_headers: base.connection_headers ?? null,
oauth_config: null,
configuration_state: base.configuration_state ?? null,
metadata: null,
metadata: base.metadata ?? null,
tools: null,
bindings: null,
status: "inactive",
Expand Down
17 changes: 14 additions & 3 deletions apps/mesh/src/web/routes/orgs/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import type {
} from "@/tools/connection/schema";
import { EnvVarsEditor } from "@/web/components/env-vars-editor";
import {
buildRegistryTitleMap,
extractConnectionData,
getRegistryItemAppName,
} from "@/web/utils/extract-connection-data";
Expand All @@ -136,6 +137,7 @@ import {

import {
groupConnections,
getConnectionDisplayTitle,
type ConnectionGroup,
} from "@/shared/utils/group-connections";

Expand Down Expand Up @@ -552,6 +554,9 @@ function CatalogItemCard({
const icon =
item.server?.icons?.[0]?.src ||
getGitHubAvatarUrl(item.server?.repository) ||
item.icon ||
item.image ||
item.logo ||
null;
const appInstances = allConnections.filter(
(c) => c.connection_type !== "VIRTUAL" && c.app_name === appName,
Expand Down Expand Up @@ -695,8 +700,6 @@ function ConnectionResults({
return true;
});

const grouped = groupConnections(filteredConnections);

const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
Expand All @@ -716,6 +719,8 @@ function ConnectionResults({
listState.searchTerm,
);
const registryItems = mergedDiscovery.items;
const registryTitles = buildRegistryTitleMap(registryItems);
const grouped = groupConnections(filteredConnections, registryTitles);

const catalogSentinelRef = useInfiniteScroll(
mergedDiscovery.loadMore,
Expand Down Expand Up @@ -1054,7 +1059,13 @@ function ConnectionResults({
return (
<ConnectionCard
key={connection.id}
connection={connection}
connection={{
...connection,
title:
(connection.app_name &&
registryTitles.get(connection.app_name)) ||
getConnectionDisplayTitle(connection),
}}
fallbackIcon={<Container />}
onClick={() =>
selectionMode
Expand Down
29 changes: 29 additions & 0 deletions apps/mesh/src/web/utils/extract-connection-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ export function getRegistryItemAppName(
return meshMeta?.appName || item.server?.name || null;
}

/**
* Build a map from app_name → display title from a list of registry items.
* Used so connected cards can show the same title as the store catalog.
*/
export function buildRegistryTitleMap(
items: Pick<RegistryItem, "_meta" | "server" | "title" | "name" | "id">[],
): Map<string, string> {
const map = new Map<string, string>();
for (const item of items) {
const appName = getRegistryItemAppName(item);
if (!appName || map.has(appName)) continue;
const meshMeta = item._meta?.["mcp.mesh"] as
| Record<string, string>
| undefined;
const title =
meshMeta?.friendlyName ||
meshMeta?.friendly_name ||
item.server?.title ||
item.title ||
item.server?.name ||
item.name ||
item.id ||
"";
if (title) map.set(appName, title);
}
return map;
}

/**
* Get a display name for a remote endpoint
* Uses the hostname (without common suffixes) as the display name
Expand Down Expand Up @@ -204,6 +232,7 @@ export function extractConnectionData(
configuration_scopes: configScopes ?? null,
metadata: {
source: "store",
displayName: baseName,
registry_item_id: item.id,
verified: meshMeta?.verified ?? false,
scopeName: meshMeta?.scopeName ?? null,
Expand Down
19 changes: 15 additions & 4 deletions apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { groupConnections } from "@/shared/utils/group-connections";
import {
groupConnections,
getConnectionDisplayTitle,
} from "@/shared/utils/group-connections";
import { CollectionSearch } from "@/web/components/collections/collection-search.tsx";
import { CollectionTabs } from "@/web/components/collections/collection-tabs.tsx";
import { CreateConnectionDialog } from "@/web/components/connections/create-connection-dialog.tsx";
Expand All @@ -14,6 +17,7 @@ import {
import { KEYS } from "@/web/lib/query-keys";
import { authClient } from "@/web/lib/auth-client";
import {
buildRegistryTitleMap,
extractConnectionData,
getRegistryItemAppName,
} from "@/web/utils/extract-connection-data";
Expand Down Expand Up @@ -176,7 +180,6 @@ function AddConnectionDialogContent({
connectionsData?.pages.flatMap(
(p: CollectionListOutput<ConnectionEntity>) => p?.items ?? [],
) ?? [];
const grouped = groupConnections(allConnections);

// Build set of connected app names to deduplicate catalog items
const connectedAppNames = new Set(
Expand All @@ -190,6 +193,9 @@ function AddConnectionDialogContent({
deferredSearch,
);

const registryTitles = buildRegistryTitleMap(mergedDiscovery.items);
const grouped = groupConnections(allConnections, registryTitles);

const catalogSentinelRef = useInfiniteScroll(
mergedDiscovery.loadMore,
mergedDiscovery.hasMore,
Expand Down Expand Up @@ -296,6 +302,9 @@ function AddConnectionDialogContent({
const icon =
item.server?.icons?.[0]?.src ||
getGitHubAvatarUrl(item.server?.repository) ||
item.icon ||
item.image ||
item.logo ||
null;

return (
Expand Down Expand Up @@ -373,7 +382,8 @@ function AddConnectionDialogContent({
const c = item.connection;
return renderConnectedApp(
c.id,
c.title,
(c.app_name && registryTitles.get(c.app_name)) ||
getConnectionDisplayTitle(c),
c.icon,
c.description ?? null,
[c],
Expand Down Expand Up @@ -471,7 +481,7 @@ export function AddConnectionDialog({
const handleCloneAndAdd = async (base: ConnectionEntity) => {
setConnectingItemId(base.app_name ?? base.id);
try {
const baseName = base.title.replace(/\s*\(\d+\)\s*$/, "");
const baseName = getConnectionDisplayTitle(base);
const newTitle = `${baseName} (${Date.now().toString(36).slice(-4)})`;

const created = await connectionActions.create.mutateAsync({
Expand All @@ -484,6 +494,7 @@ export function AddConnectionDialog({
app_name: base.app_name ?? null,
app_id: base.app_id ?? null,
connection_headers: base.connection_headers ?? null,
metadata: base.metadata ?? null,
});
const id = created.id;

Expand Down
4 changes: 3 additions & 1 deletion apps/mesh/src/web/views/virtual-mcp/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generatePrefixedId } from "@/shared/utils/generate-id";
import { getConnectionDisplayTitle } from "@/shared/utils/group-connections";
import type { VirtualMCPEntity } from "@/tools/virtual/schema";
import { getUIResourceUri } from "@/mcp-apps/types.ts";
import { useChatTask } from "@/web/components/chat/context";
Expand Down Expand Up @@ -1185,7 +1186,7 @@ function VirtualMcpDetailViewWithData({
};
if (!base) return;

const baseName = base.title.replace(/\s*\(.*?\)\s*$/, "");
const baseName = getConnectionDisplayTitle(base);
const newId = generatePrefixedId("conn");
// Temporary title — will be updated with email suffix after OAuth if available
const tempTitle = `${baseName} (${Date.now().toString(36).slice(-4)})`;
Expand All @@ -1201,6 +1202,7 @@ function VirtualMcpDetailViewWithData({
app_name: base.app_name ?? null,
app_id: base.app_id ?? null,
connection_headers: base.connection_headers ?? null,
metadata: base.metadata ?? null,
});

// Handle OAuth if needed
Expand Down
Loading