From a705f42b08d12a0ace0f2e277a4509d4111da378 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Thu, 9 Apr 2026 16:32:23 -0300 Subject: [PATCH 1/8] fix(connections): use canonical app_name for connection display titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connection cards, group headers, detail page headers, and breadcrumbs now derive the display name from the stable app_name slug (e.g. "google-gmail" → "Google Gmail") instead of the first instance's mutable title. This prevents user-renamed instances from polluting catalog cards and group titles. Instance-specific names still appear in the instances panel and binding selector where they belong. Also adds icon fallbacks (item.icon, item.image, item.logo) for non-store registry items in the connections catalog and add-connection dialog. Co-Authored-By: Claude Sonnet 4.6 --- .../src/shared/utils/group-connections.ts | 25 ++++++++++++++++--- .../components/details/connection/index.tsx | 17 +++---------- apps/mesh/src/web/routes/orgs/connections.tsx | 9 ++++++- .../virtual-mcp/add-connection-dialog.tsx | 12 ++++++--- apps/mesh/src/web/views/virtual-mcp/index.tsx | 3 ++- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index d222a46fdf..a4f8a81ae3 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -1,6 +1,27 @@ import type { ConnectionEntity } from "@decocms/mesh-sdk"; import { getConnectionSlug } from "./connection-slug"; +/** + * Returns the canonical display title for a connection in catalog/card/header contexts. + * For registry-installed connections (those with app_name set), the canonical name is + * derived from the stable app_name slug so that user-renamed instances don't pollute + * the group title. For custom connections (no app_name), the user-set title is used. + * + * Use the raw connection.title only when showing the specific instance matters + * (e.g., the instance list inside a connection detail, or the binding selector). + */ +export function getConnectionDisplayTitle( + connection: ConnectionEntity, +): string { + if (connection.app_name) { + // Convert slug → display title: "google-gmail" → "Google Gmail" + // Strip optional scope prefix like "@scope/name" first. + const slug = connection.app_name.replace(/^@[^/]+\//, ""); + return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + } + return connection.title; +} + export interface ConnectionGroup { type: "group"; key: string; @@ -47,9 +68,7 @@ export function groupConnections( type: "group", key, icon: first.icon, - title: first.app_name - ? first.title.replace(/\s*\(\d+\)\s*$/, "") - : first.title, + title: getConnectionDisplayTitle(first), connections: bucket, }); } diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 481cb5cc7f..c8414ec675 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -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"; @@ -424,12 +425,7 @@ function ConnectionInspectorViewWithConnection({ - {(() => { - const first = siblings[0] ?? connection; - return first.app_name - ? first.title.replace(/\s*\(\d+\)\s*$/, "") - : first.title; - })()} + {getConnectionDisplayTitle(siblings[0] ?? connection)} @@ -562,12 +558,7 @@ function ConnectionInspectorViewWithConnection({
{ - const first = siblings[0] ?? connection; - return first.app_name - ? first.title.replace(/\s*\(\d+\)\s*$/, "") - : first.title; - })()} + displayTitle={getConnectionDisplayTitle(siblings[0] ?? connection)} />
@@ -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"); diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index 46f74cff5b..cfde898b15 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -136,6 +136,7 @@ import { import { groupConnections, + getConnectionDisplayTitle, type ConnectionGroup, } from "@/shared/utils/group-connections"; @@ -552,6 +553,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, @@ -1054,7 +1058,10 @@ function ConnectionResults({ return ( } onClick={() => selectionMode diff --git a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 2b1a0e26dd..2341cf80a8 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -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"; @@ -296,6 +299,9 @@ function AddConnectionDialogContent({ const icon = item.server?.icons?.[0]?.src || getGitHubAvatarUrl(item.server?.repository) || + item.icon || + item.image || + item.logo || null; return ( @@ -373,7 +379,7 @@ function AddConnectionDialogContent({ const c = item.connection; return renderConnectedApp( c.id, - c.title, + getConnectionDisplayTitle(c), c.icon, c.description ?? null, [c], @@ -471,7 +477,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({ diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 028cf77ddd..713c529ca4 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -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"; @@ -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)})`; From 5eb3b71ca2ada13020bb981276ede2f3edb34c38 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Thu, 9 Apr 2026 16:40:45 -0300 Subject: [PATCH 2/8] fix(home): fix connections modal add button and added state The home connections dialog was passing an empty set for addedConnectionIds (so everything looked un-added) and a no-op for onAdd (so clicking Add did nothing). Now loads all org connections to mark existing ones as Added, and closes the dialog after a new connection is installed. Co-Authored-By: Claude Sonnet 4.6 --- apps/mesh/src/web/components/chat/input.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index b2c1943867..9592c7403a 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -11,6 +11,7 @@ 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"; @@ -320,6 +321,8 @@ export function ChatInput({ const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); + const existingConnections = useConnections(); + const existingConnectionIds = new Set(existingConnections.map((c) => c.id)); // Navigate to the agent route (like the sidebar does) instead of only // setting an ephemeral search-param override, so the thread list re-scopes. @@ -651,8 +654,8 @@ export function ChatInput({ {}} + addedConnectionIds={existingConnectionIds} + onAdd={() => setConnectionsOpen(false)} defaultTab="all" /> From 2f969ddb81f29cfaf66028ad34b9816f0afde08c Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 08:16:31 -0300 Subject: [PATCH 3/8] fix(connections): prevent stacked suffixes and defer connections fetch - Strip trailing (N) suffix from getConnectionDisplayTitle result before appending the next instance number, fixing "Name (2) (3)" stacking for custom connections without app_name. - Move useConnections and AddConnectionDialog into HomeConnectionsDialog, which only mounts when showConnectionsBanner is true, so connections are not fetched unconditionally in all chat views. Co-Authored-By: Claude Sonnet 4.6 --- apps/mesh/src/web/components/chat/input.tsx | 36 ++++++++++++++----- .../components/details/connection/index.tsx | 5 ++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 9592c7403a..ba00147bbb 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -14,6 +14,7 @@ import { useConnections, useProjectContext, } from "@decocms/mesh-sdk"; + import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { ArrowUp, @@ -57,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 ( + onOpenChange(false)} + defaultTab="all" + /> + ); +} + // ============================================================================ // VirtualMCPBadge - Internal component for displaying selected virtual MCP // ============================================================================ @@ -321,8 +342,6 @@ export function ChatInput({ const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); - const existingConnections = useConnections(); - const existingConnectionIds = new Set(existingConnections.map((c) => c.id)); // Navigate to the agent route (like the sidebar does) instead of only // setting an ephemeral search-param override, so the thread list re-scopes. @@ -651,13 +670,12 @@ export function ChatInput({
- setConnectionsOpen(false)} - defaultTab="all" - /> + {showConnectionsBanner && ( + + )} ); } diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index c8414ec675..5fc7a2b043 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -576,7 +576,10 @@ function ConnectionInspectorViewWithConnection({ setIsAddingInstance(true); try { const base = siblings[0] ?? connection; - const baseName = getConnectionDisplayTitle(base); + const baseName = getConnectionDisplayTitle(base).replace( + /\s*\(\d+\)\s*$/, + "", + ); const nextNumber = siblings.length + 1; const newTitle = `${baseName} (${nextNumber})`; const newId = generatePrefixedId("conn"); From 1ea4993390987005114e5b48925b5c7de46c241d Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 08:53:21 -0300 Subject: [PATCH 4/8] fix(connections): use original title instead of slug for display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slug-to-title conversion ("vercel-mcp" → "Vercel Mcp") was lossy and couldn't preserve correct casing for acronyms like MCP or API. Instead, strip auto-generated instance suffixes (1-6 char parenthesized suffixes) from the original connection title which preserves the correct casing from install time. For groups, pick the shortest stripped title among all instances so user-renamed instances don't pollute the group title. Also extracts HomeConnectionsDialog to avoid unconditional useConnections() in ChatInput, and prevents stacked numeric suffixes when adding instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/shared/utils/group-connections.ts | 38 ++++++++++++++----- .../components/details/connection/index.tsx | 23 +++++++---- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index a4f8a81ae3..6159450f1f 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -1,11 +1,18 @@ 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, which + * covers numeric instance numbers and short base-36 clone IDs. Longer + * parenthesized qualifiers (e.g., "(Desktop)") are preserved. + */ +const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/; + /** * Returns the canonical display title for a connection in catalog/card/header contexts. - * For registry-installed connections (those with app_name set), the canonical name is - * derived from the stable app_name slug so that user-renamed instances don't pollute - * the group title. For custom connections (no app_name), the user-set title is used. + * Strips auto-generated instance suffixes from the connection title so that + * "Vercel MCP (2)" displays as "Vercel MCP". * * Use the raw connection.title only when showing the specific instance matters * (e.g., the instance list inside a connection detail, or the binding selector). @@ -13,13 +20,24 @@ import { getConnectionSlug } from "./connection-slug"; export function getConnectionDisplayTitle( connection: ConnectionEntity, ): string { - if (connection.app_name) { - // Convert slug → display title: "google-gmail" → "Google Gmail" - // Strip optional scope prefix like "@scope/name" first. - const slug = connection.app_name.replace(/^@[^/]+\//, ""); - return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + return connection.title.replace(INSTANCE_SUFFIX_RE, ""); +} + +/** + * For a group of connections sharing the same app, pick the best canonical title. + * Uses the shortest stripped title among all instances so that user-renamed + * instances (e.g., "Google Gmail adsfadsfa") don't pollute the group title + * when a sibling still has the clean original name. + */ +export function getGroupDisplayTitle(connections: ConnectionEntity[]): string { + let best = getConnectionDisplayTitle(connections[0]!); + for (let i = 1; i < connections.length; i++) { + const candidate = getConnectionDisplayTitle(connections[i]!); + if (candidate.length < best.length) { + best = candidate; + } } - return connection.title; + return best; } export interface ConnectionGroup { @@ -68,7 +86,7 @@ export function groupConnections( type: "group", key, icon: first.icon, - title: getConnectionDisplayTitle(first), + title: getGroupDisplayTitle(bucket), connections: bucket, }); } diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 5fc7a2b043..4030f4720c 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -1,5 +1,8 @@ import { generatePrefixedId } from "@/shared/utils/generate-id"; -import { getConnectionDisplayTitle } from "@/shared/utils/group-connections"; +import { + getConnectionDisplayTitle, + getGroupDisplayTitle, +} 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"; @@ -425,7 +428,9 @@ function ConnectionInspectorViewWithConnection({ - {getConnectionDisplayTitle(siblings[0] ?? connection)} + {siblings.length > 1 + ? getGroupDisplayTitle(siblings) + : getConnectionDisplayTitle(siblings[0] ?? connection)} @@ -558,7 +563,11 @@ function ConnectionInspectorViewWithConnection({
1 + ? getGroupDisplayTitle(siblings) + : getConnectionDisplayTitle(siblings[0] ?? connection) + } />
@@ -576,10 +585,10 @@ function ConnectionInspectorViewWithConnection({ setIsAddingInstance(true); try { const base = siblings[0] ?? connection; - const baseName = getConnectionDisplayTitle(base).replace( - /\s*\(\d+\)\s*$/, - "", - ); + const baseName = + siblings.length > 1 + ? getGroupDisplayTitle(siblings) + : getConnectionDisplayTitle(base); const nextNumber = siblings.length + 1; const newTitle = `${baseName} (${nextNumber})`; const newId = generatePrefixedId("conn"); From 64fd51a56ba9f33df48eacc581500c3101f14d06 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 08:55:23 -0300 Subject: [PATCH 5/8] fix(connections): preserve original casing for display titles Use title when it matches app_name slug (preserves casing like "Vercel MCP"), fall back to slug-to-title only when the instance was renamed. For groups, scan all instances to find one with the original title before falling back. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/shared/utils/group-connections.ts | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index 6159450f1f..0f7530c642 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -1,18 +1,28 @@ import type { ConnectionEntity } from "@decocms/mesh-sdk"; import { getConnectionSlug } from "./connection-slug"; +import { slugify } from "./slugify"; /** * Strip auto-generated instance suffixes like "(2)" or "(a1b2)" from a title. - * Matches 1-6 character parenthesized suffixes at the end of the string, which - * covers numeric instance numbers and short base-36 clone IDs. Longer - * parenthesized qualifiers (e.g., "(Desktop)") are preserved. */ const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/; +/** + * Convert an app_name slug to a display title as a last resort. + * "google-gmail" → "Google Gmail", "@scope/tool" → "Tool" + */ +function slugToTitle(appName: string): string { + const slug = appName.replace(/^@[^/]+\//, ""); + return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + /** * Returns the canonical display title for a connection in catalog/card/header contexts. - * Strips auto-generated instance suffixes from the connection title so that - * "Vercel MCP (2)" displays as "Vercel MCP". + * + * Strategy: + * 1. Strip auto-generated instance suffixes from the title ("Vercel MCP (2)" → "Vercel MCP") + * 2. If the stripped title still matches the app_name slug, use it (preserves original casing) + * 3. If it doesn't match (user renamed the instance), fall back to slug → title conversion * * Use the raw connection.title only when showing the specific instance matters * (e.g., the instance list inside a connection detail, or the binding selector). @@ -20,16 +30,40 @@ const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/; export function getConnectionDisplayTitle( connection: ConnectionEntity, ): string { - return connection.title.replace(INSTANCE_SUFFIX_RE, ""); + const stripped = connection.title.replace(INSTANCE_SUFFIX_RE, ""); + if (!connection.app_name) return stripped; + + // If slugifying the stripped title matches app_name, the title is original — use it + if (slugify(stripped) === connection.app_name) { + return stripped; + } + + // Title was renamed — fall back to slug conversion + return slugToTitle(connection.app_name); } /** * For a group of connections sharing the same app, pick the best canonical title. - * Uses the shortest stripped title among all instances so that user-renamed - * instances (e.g., "Google Gmail adsfadsfa") don't pollute the group title - * when a sibling still has the clean original name. + * Prefers the original (non-renamed) title from any instance to preserve correct + * casing. Falls back to the shortest stripped title. */ export function getGroupDisplayTitle(connections: ConnectionEntity[]): string { + const appName = connections[0]!.app_name; + + // First pass: look for an instance whose title still matches the app_name + // (i.e. hasn't been renamed). This preserves original casing like "Vercel MCP". + if (appName) { + for (const c of connections) { + const stripped = c.title.replace(INSTANCE_SUFFIX_RE, ""); + if (slugify(stripped) === appName) { + return stripped; + } + } + // All instances were renamed — fall back to slug conversion + return slugToTitle(appName); + } + + // No app_name — pick the shortest stripped title let best = getConnectionDisplayTitle(connections[0]!); for (let i = 1; i < connections.length; i++) { const candidate = getConnectionDisplayTitle(connections[i]!); From 6fc83283dde88fa7b23a6cf814840210a0378a97 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 08:59:08 -0300 Subject: [PATCH 6/8] fix(connections): allow word-boundary prefix matching for display titles The exact slugify match failed when the registry title ("Vercel") was shorter than app_name ("vercel-mcp"). Now checks word-boundary prefixes in both directions so "Vercel" matches "vercel-mcp" and preserves the original casing instead of falling back to the lossy slug conversion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/shared/utils/group-connections.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index 0f7530c642..52c579b5b1 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -16,12 +16,27 @@ function slugToTitle(appName: string): string { return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } +/** + * Check whether a stripped title looks like the original (not user-renamed) + * by comparing its slug against app_name. Allows partial matches at word + * boundaries so that "Vercel" matches "vercel-mcp" and "Vercel MCP Server" + * matches "vercel-mcp". + */ +function isOriginalTitle(titleSlug: string, appName: string): boolean { + return ( + titleSlug === appName || + appName.startsWith(titleSlug + "-") || + titleSlug.startsWith(appName + "-") + ); +} + /** * Returns the canonical display title for a connection in catalog/card/header contexts. * * Strategy: * 1. Strip auto-generated instance suffixes from the title ("Vercel MCP (2)" → "Vercel MCP") - * 2. If the stripped title still matches the app_name slug, use it (preserves original casing) + * 2. If the stripped title still matches the app_name slug (exact or word-boundary prefix), + * use it — this preserves the original casing from the registry (e.g., "Vercel MCP") * 3. If it doesn't match (user renamed the instance), fall back to slug → title conversion * * Use the raw connection.title only when showing the specific instance matters @@ -33,8 +48,7 @@ export function getConnectionDisplayTitle( const stripped = connection.title.replace(INSTANCE_SUFFIX_RE, ""); if (!connection.app_name) return stripped; - // If slugifying the stripped title matches app_name, the title is original — use it - if (slugify(stripped) === connection.app_name) { + if (isOriginalTitle(slugify(stripped), connection.app_name)) { return stripped; } @@ -55,7 +69,7 @@ export function getGroupDisplayTitle(connections: ConnectionEntity[]): string { if (appName) { for (const c of connections) { const stripped = c.title.replace(INSTANCE_SUFFIX_RE, ""); - if (slugify(stripped) === appName) { + if (isOriginalTitle(slugify(stripped), appName)) { return stripped; } } From 4c1ddbec594d7b6d47652dea18184e27b08a552c Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 09:09:17 -0300 Subject: [PATCH 7/8] fix(connections): use registry titles for connected cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of complex slug matching, simply look up the correct display title from the registry items that are already loaded. Adds buildRegistryTitleMap() which maps app_name → registry display title, and passes it to groupConnections(). Connected cards now show the exact same name as the store catalog. Falls back to suffix-stripped connection title for custom connections or when registry data is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/shared/utils/group-connections.ts | 89 +++---------------- .../components/details/connection/index.tsx | 20 +---- apps/mesh/src/web/routes/orgs/connections.tsx | 10 ++- .../src/web/utils/extract-connection-data.ts | 28 ++++++ .../virtual-mcp/add-connection-dialog.tsx | 8 +- 5 files changed, 58 insertions(+), 97 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index 52c579b5b1..fce072e3cd 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -1,91 +1,20 @@ import type { ConnectionEntity } from "@decocms/mesh-sdk"; import { getConnectionSlug } from "./connection-slug"; -import { slugify } from "./slugify"; /** * 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*$/; /** - * Convert an app_name slug to a display title as a last resort. - * "google-gmail" → "Google Gmail", "@scope/tool" → "Tool" - */ -function slugToTitle(appName: string): string { - const slug = appName.replace(/^@[^/]+\//, ""); - return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - -/** - * Check whether a stripped title looks like the original (not user-renamed) - * by comparing its slug against app_name. Allows partial matches at word - * boundaries so that "Vercel" matches "vercel-mcp" and "Vercel MCP Server" - * matches "vercel-mcp". - */ -function isOriginalTitle(titleSlug: string, appName: string): boolean { - return ( - titleSlug === appName || - appName.startsWith(titleSlug + "-") || - titleSlug.startsWith(appName + "-") - ); -} - -/** - * Returns the canonical display title for a connection in catalog/card/header contexts. - * - * Strategy: - * 1. Strip auto-generated instance suffixes from the title ("Vercel MCP (2)" → "Vercel MCP") - * 2. If the stripped title still matches the app_name slug (exact or word-boundary prefix), - * use it — this preserves the original casing from the registry (e.g., "Vercel MCP") - * 3. If it doesn't match (user renamed the instance), fall back to slug → title conversion - * - * Use the raw connection.title only when showing the specific instance matters - * (e.g., the instance list inside a connection detail, or the binding selector). + * Strip instance suffix from a connection title. + * "Vercel (2)" → "Vercel", "Gmail (a1b2)" → "Gmail" */ export function getConnectionDisplayTitle( connection: ConnectionEntity, ): string { - const stripped = connection.title.replace(INSTANCE_SUFFIX_RE, ""); - if (!connection.app_name) return stripped; - - if (isOriginalTitle(slugify(stripped), connection.app_name)) { - return stripped; - } - - // Title was renamed — fall back to slug conversion - return slugToTitle(connection.app_name); -} - -/** - * For a group of connections sharing the same app, pick the best canonical title. - * Prefers the original (non-renamed) title from any instance to preserve correct - * casing. Falls back to the shortest stripped title. - */ -export function getGroupDisplayTitle(connections: ConnectionEntity[]): string { - const appName = connections[0]!.app_name; - - // First pass: look for an instance whose title still matches the app_name - // (i.e. hasn't been renamed). This preserves original casing like "Vercel MCP". - if (appName) { - for (const c of connections) { - const stripped = c.title.replace(INSTANCE_SUFFIX_RE, ""); - if (isOriginalTitle(slugify(stripped), appName)) { - return stripped; - } - } - // All instances were renamed — fall back to slug conversion - return slugToTitle(appName); - } - - // No app_name — pick the shortest stripped title - let best = getConnectionDisplayTitle(connections[0]!); - for (let i = 1; i < connections.length; i++) { - const candidate = getConnectionDisplayTitle(connections[i]!); - if (candidate.length < best.length) { - best = candidate; - } - } - return best; + return connection.title.replace(INSTANCE_SUFFIX_RE, ""); } export interface ConnectionGroup { @@ -103,8 +32,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, ): GroupedItem[] { const buckets = new Map(); for (const c of connections) { @@ -127,6 +62,8 @@ 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 { @@ -134,7 +71,7 @@ export function groupConnections( type: "group", key, icon: first.icon, - title: getGroupDisplayTitle(bucket), + title: registryTitle || getConnectionDisplayTitle(first), connections: bucket, }); } diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index 4030f4720c..c8414ec675 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -1,8 +1,5 @@ import { generatePrefixedId } from "@/shared/utils/generate-id"; -import { - getConnectionDisplayTitle, - getGroupDisplayTitle, -} from "@/shared/utils/group-connections"; +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"; @@ -428,9 +425,7 @@ function ConnectionInspectorViewWithConnection({ - {siblings.length > 1 - ? getGroupDisplayTitle(siblings) - : getConnectionDisplayTitle(siblings[0] ?? connection)} + {getConnectionDisplayTitle(siblings[0] ?? connection)} @@ -563,11 +558,7 @@ function ConnectionInspectorViewWithConnection({
1 - ? getGroupDisplayTitle(siblings) - : getConnectionDisplayTitle(siblings[0] ?? connection) - } + displayTitle={getConnectionDisplayTitle(siblings[0] ?? connection)} />
@@ -585,10 +576,7 @@ function ConnectionInspectorViewWithConnection({ setIsAddingInstance(true); try { const base = siblings[0] ?? connection; - const baseName = - siblings.length > 1 - ? getGroupDisplayTitle(siblings) - : getConnectionDisplayTitle(base); + const baseName = getConnectionDisplayTitle(base); const nextNumber = siblings.length + 1; const newTitle = `${baseName} (${nextNumber})`; const newId = generatePrefixedId("conn"); diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index cfde898b15..11c1529b7d 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -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"; @@ -699,8 +700,6 @@ function ConnectionResults({ return true; }); - const grouped = groupConnections(filteredConnections); - const toggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev); @@ -720,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, @@ -1060,7 +1061,10 @@ function ConnectionResults({ key={connection.id} connection={{ ...connection, - title: getConnectionDisplayTitle(connection), + title: + (connection.app_name && + registryTitles.get(connection.app_name)) || + getConnectionDisplayTitle(connection), }} fallbackIcon={} onClick={() => diff --git a/apps/mesh/src/web/utils/extract-connection-data.ts b/apps/mesh/src/web/utils/extract-connection-data.ts index 693a260f5f..c4034e2750 100644 --- a/apps/mesh/src/web/utils/extract-connection-data.ts +++ b/apps/mesh/src/web/utils/extract-connection-data.ts @@ -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[], +): Map { + const map = new Map(); + for (const item of items) { + const appName = getRegistryItemAppName(item); + if (!appName || map.has(appName)) continue; + const meshMeta = item._meta?.["mcp.mesh"] as + | Record + | 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 diff --git a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 2341cf80a8..2f1007c9f9 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -17,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"; @@ -179,7 +180,6 @@ function AddConnectionDialogContent({ connectionsData?.pages.flatMap( (p: CollectionListOutput) => p?.items ?? [], ) ?? []; - const grouped = groupConnections(allConnections); // Build set of connected app names to deduplicate catalog items const connectedAppNames = new Set( @@ -193,6 +193,9 @@ function AddConnectionDialogContent({ deferredSearch, ); + const registryTitles = buildRegistryTitleMap(mergedDiscovery.items); + const grouped = groupConnections(allConnections, registryTitles); + const catalogSentinelRef = useInfiniteScroll( mergedDiscovery.loadMore, mergedDiscovery.hasMore, @@ -379,7 +382,8 @@ function AddConnectionDialogContent({ const c = item.connection; return renderConnectedApp( c.id, - getConnectionDisplayTitle(c), + (c.app_name && registryTitles.get(c.app_name)) || + getConnectionDisplayTitle(c), c.icon, c.description ?? null, [c], From 0de74e82ae51c795814b5762e6d0d3598feb6d52 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 10 Apr 2026 10:00:47 -0300 Subject: [PATCH 8/8] fix(connections): persist displayName in metadata for stable titles Store the canonical display name (baseName from registry) in connection metadata at creation time so the detail page header and breadcrumb can show the correct name even after a user renames the instance. getConnectionDisplayTitle now checks metadata.displayName first. Clone operations propagate metadata to new instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/shared/utils/group-connections.ts | 9 +++++++-- .../mesh/src/web/components/details/connection/index.tsx | 2 +- apps/mesh/src/web/utils/extract-connection-data.ts | 1 + .../src/web/views/virtual-mcp/add-connection-dialog.tsx | 1 + apps/mesh/src/web/views/virtual-mcp/index.tsx | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/mesh/src/shared/utils/group-connections.ts b/apps/mesh/src/shared/utils/group-connections.ts index fce072e3cd..1e577edaca 100644 --- a/apps/mesh/src/shared/utils/group-connections.ts +++ b/apps/mesh/src/shared/utils/group-connections.ts @@ -8,12 +8,17 @@ import { getConnectionSlug } from "./connection-slug"; const INSTANCE_SUFFIX_RE = /\s*\([^)]{1,6}\)\s*$/; /** - * Strip instance suffix from a connection title. - * "Vercel (2)" → "Vercel", "Gmail (a1b2)" → "Gmail" + * 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 | null; + if (metadata?.displayName && typeof metadata.displayName === "string") { + return metadata.displayName; + } return connection.title.replace(INSTANCE_SUFFIX_RE, ""); } diff --git a/apps/mesh/src/web/components/details/connection/index.tsx b/apps/mesh/src/web/components/details/connection/index.tsx index c8414ec675..ddcf6e7346 100644 --- a/apps/mesh/src/web/components/details/connection/index.tsx +++ b/apps/mesh/src/web/components/details/connection/index.tsx @@ -597,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", diff --git a/apps/mesh/src/web/utils/extract-connection-data.ts b/apps/mesh/src/web/utils/extract-connection-data.ts index c4034e2750..b88f578382 100644 --- a/apps/mesh/src/web/utils/extract-connection-data.ts +++ b/apps/mesh/src/web/utils/extract-connection-data.ts @@ -232,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, diff --git a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx index 2f1007c9f9..839b00e847 100644 --- a/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/add-connection-dialog.tsx @@ -494,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; diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index 713c529ca4..ac73f3a4e5 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -1202,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