diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 74f45e01e4..c28005dc80 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -1,6 +1,6 @@ { "name": "@decocms/mesh", - "version": "2.59.2", + "version": "2.64.1", "description": "MCP Mesh - Self-hostable MCP Gateway for managing AI connections and tools", "author": "Deco team", "license": "MIT", diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 3a09aba447..f10319eb1a 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -11,8 +11,8 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import type { MeshContext } from "@/core/mesh-context"; +import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; import { generatePrefixedId } from "@/shared/utils/generate-id"; - import { Metadata } from "@/web/components/chat/types"; import { DECOPILOT_BASE_PROMPT, @@ -24,7 +24,6 @@ import { ensureOrganization, toolsFromMCP } from "./helpers"; import { createModelProviderFromProxy } from "./model-provider"; import { StreamRequestSchema } from "./schemas"; import { generateTitleInBackground } from "./title-generator"; -import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; // ============================================================================ // Request Validation @@ -38,7 +37,18 @@ async function validateRequest( const parseResult = StreamRequestSchema.safeParse(rawPayload); if (!parseResult.success) { - throw new HTTPException(400, { message: "Invalid request body" }); + // Log detalhes do erro de validação para debug + console.error("[Decopilot] ❌ Request validation failed:"); + console.error( + "Validation errors:", + JSON.stringify(parseResult.error.format(), null, 2), + ); + console.error("Raw payload:", JSON.stringify(rawPayload, null, 2)); + + throw new HTTPException(400, { + message: "Invalid request body", + cause: parseResult.error.format(), + }); } return { @@ -72,9 +82,8 @@ app.post("/:org/decopilot/stream", async (c) => { const threadId = thread_id ?? memoryConfig?.threadId; // Create virtual MCP client and model provider in parallel - // For virtual MCPs (agents), we need to use storage.virtualMcps.findById which handles null const [virtualMcp, modelClient] = await Promise.all([ - ctx.storage.virtualMcps.findById(agent.id ?? null, organization.id), + ctx.storage.virtualMcps.findById(agent.id, organization.id), ctx.createMCPProxy(model.connectionId), ]); @@ -85,7 +94,7 @@ app.post("/:org/decopilot/stream", async (c) => { const mcpClient = await createVirtualClientFrom( virtualMcp, ctx, - "code_execution", + agent.mode, ); // 2. Extract tools from virtual MCP client and create model provider @@ -177,7 +186,7 @@ app.post("/:org/decopilot/stream", async (c) => { messageMetadata: ({ part }): Metadata => { if (part.type === "start") { return { - agent: { id: agent.id ?? null }, + agent: { id: agent.id ?? null, mode: agent.mode }, model: { id: model.id, connectionId: model.connectionId }, created_at: new Date(), thread_id: memory.thread.id, diff --git a/apps/mesh/src/api/routes/decopilot/schemas.ts b/apps/mesh/src/api/routes/decopilot/schemas.ts index 0fcedd332a..4a983afbba 100644 --- a/apps/mesh/src/api/routes/decopilot/schemas.ts +++ b/apps/mesh/src/api/routes/decopilot/schemas.ts @@ -58,7 +58,12 @@ export const StreamRequestSchema = z.object({ .optional(), }) .loose(), - agent: z.object({ id: z.string() }).loose(), + agent: z + .object({ + id: z.string(), + mode: z.enum(["passthrough", "smart_tool_selection", "code_execution"]), + }) + .loose(), stream: z.boolean().optional(), temperature: z.number().default(0.5), thread_id: z.string().optional(), diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index f9d803652f..cf9cef5be7 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -221,6 +221,12 @@ export function toServerClient(client: MCPProxyClient): ServerClient { }; } +const DEFAULT_SERVER_CAPABILITIES = { + tools: {}, + resources: {}, + prompts: {}, +}; + async function createMCPProxyDoNotUseDirectly( connectionIdOrConnection: string | ConnectionEntity, ctx: MeshContext, @@ -558,6 +564,11 @@ async function createMCPProxyDoNotUseDirectly( params: GetPromptRequest["params"], ): Promise => client.getPrompt(params); + // We are currently exposing the underlying client with tools/resources/prompts capabilities + // This way we have an uniform API the frontend can leverage from. + // Frontend connects to mesh. It's garatee that all mcps have the necessary capabilities. The UI works consistently. + const getServerCapabilities = () => DEFAULT_SERVER_CAPABILITIES; + return { callTool: (params: CallToolRequest["params"]) => executeToolCall({ @@ -570,7 +581,7 @@ async function createMCPProxyDoNotUseDirectly( listResourceTemplates, listPrompts, getPrompt, - getServerCapabilities: () => client.getServerCapabilities(), + getServerCapabilities, getInstructions: () => client.getInstructions(), close: () => client.close(), callStreamableTool, @@ -653,16 +664,7 @@ app.all("/:connectionId", async (c) => { await server.connect(transport); // Handle request and cleanup - try { - return await transport.handleRequest(c.req.raw); - } finally { - // Close the transport - try { - await transport.close?.(); - } catch { - // Ignore close errors - transport may already be closed - } - } + return await transport.handleRequest(c.req.raw); } catch (error) { // Check if this is an auth error - if so, return appropriate 401 // Note: This only applies to HTTP connections diff --git a/apps/mesh/src/api/routes/virtual-mcp.ts b/apps/mesh/src/api/routes/virtual-mcp.ts index 839f9266ad..57685c15f9 100644 --- a/apps/mesh/src/api/routes/virtual-mcp.ts +++ b/apps/mesh/src/api/routes/virtual-mcp.ts @@ -14,7 +14,7 @@ * - Supports exclusion strategy for inverse tool selection */ -import { createServerFromClient } from "@decocms/mesh-sdk"; +import { createServerFromClient, getDecopilotId } from "@decocms/mesh-sdk"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { Hono } from "hono"; import type { MeshContext } from "../../core/mesh-context"; @@ -62,8 +62,18 @@ export async function handleVirtualMcpRequest( .then((org) => org?.id) : null; + const virtualId = virtualMcpId + ? virtualMcpId + : organizationId + ? getDecopilotId(organizationId) + : null; + + if (!virtualId) { + return c.json({ error: "Agent ID or organization ID is required" }, 400); + } + const virtualMcp = await ctx.storage.virtualMcps.findById( - virtualMcpId ?? null, + virtualId, organizationId ?? undefined, ); @@ -85,6 +95,7 @@ export async function handleVirtualMcpRequest( } // Set connection context (Virtual MCPs are now connections) + // Note: virtualMcp.id can be null for Decopilot agent, but connectionId should be set for routing ctx.connectionId = virtualMcp.id ?? undefined; // Set organization context @@ -130,7 +141,6 @@ export async function handleVirtualMcpRequest( // Connect server to transport await server.connect(transport); - // Handle the incoming MCP message return await transport.handleRequest(c.req.raw); } catch (error) { const err = error as Error; diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts index d6b0f1caba..05df0ab260 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts @@ -371,6 +371,6 @@ export class PassthroughClient extends Client { * Get server instructions from virtual MCP metadata */ override getInstructions(): string | undefined { - return this.options.virtualMcp.metadata?.instructions; + return this.options.virtualMcp.metadata?.instructions ?? undefined; } } diff --git a/apps/mesh/src/storage/connection.ts b/apps/mesh/src/storage/connection.ts index 926ce2ae37..83c4642396 100644 --- a/apps/mesh/src/storage/connection.ts +++ b/apps/mesh/src/storage/connection.ts @@ -102,23 +102,10 @@ export class ConnectionStorage implements ConnectionStoragePort { organizationId?: string, ): Promise { // Handle Decopilot ID - return Decopilot connection entity - if (isDecopilot(id)) { - if (!organizationId) { - // Extract orgId from decopilot_{orgId} pattern - const orgIdMatch = id.match(/^decopilot_(.+)$/); - if (!orgIdMatch) { - throw new Error( - `Invalid Decopilot ID format: ${id}. Expected decopilot_{orgId}`, - ); - } - organizationId = orgIdMatch[1]; - } - if (!organizationId) { - throw new Error( - `Organization ID is required for Decopilot connection: ${id}`, - ); - } - return getWellKnownDecopilotConnection(organizationId); + const decopilotOrgId = isDecopilot(id); + if (decopilotOrgId) { + const resolvedOrgId = organizationId ?? decopilotOrgId; + return getWellKnownDecopilotConnection(resolvedOrgId); } let query = this.db diff --git a/apps/mesh/src/storage/ports.ts b/apps/mesh/src/storage/ports.ts index cfbd98992c..813ef58d17 100644 --- a/apps/mesh/src/storage/ports.ts +++ b/apps/mesh/src/storage/ports.ts @@ -129,7 +129,7 @@ export interface VirtualMCPStoragePort { data: VirtualMCPCreateData, ): Promise; findById( - id: string | null, + id: string, organizationId?: string, ): Promise; list(organizationId: string): Promise; diff --git a/apps/mesh/src/storage/virtual.ts b/apps/mesh/src/storage/virtual.ts index dfe94cb89c..bfb23be225 100644 --- a/apps/mesh/src/storage/virtual.ts +++ b/apps/mesh/src/storage/virtual.ts @@ -118,23 +118,13 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { } async findById( - id: string | null, + id: string, organizationId?: string, ): Promise { // Handle Decopilot ID - return Decopilot agent with all org connections - if (id && isDecopilot(id)) { - // Extract orgId from decopilot_{orgId} pattern or use provided organizationId - const orgIdMatch = id.match(/^decopilot_(.+)$/); - let resolvedOrgId: string; - if (organizationId) { - resolvedOrgId = organizationId; - } else if (orgIdMatch && orgIdMatch[1]) { - resolvedOrgId = orgIdMatch[1]; - } else { - throw new Error( - `Invalid Decopilot ID format: ${id}. Expected decopilot_{orgId} or provide organizationId`, - ); - } + const decopilotOrgId = isDecopilot(id); + if (decopilotOrgId) { + const resolvedOrgId = organizationId ?? decopilotOrgId; // Get all active connections for the organization const connections = await this.db @@ -157,35 +147,6 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { }; } - // Handle null ID - treat as Decopilot for backward compatibility - if (!id) { - if (!organizationId) { - throw new Error( - "organizationId is required when id is null (Decopilot agent)", - ); - } - - // Get all active connections for the organization - const connections = await this.db - .selectFrom("connections") - .selectAll() - .where("organization_id", "=", organizationId) - .where("status", "!=", "inactive") - .where("status", "!=", "error") - .execute(); - - // Return Decopilot agent with connections populated - return { - ...getWellKnownDecopilotVirtualMCP(organizationId), - connections: connections.map((c) => ({ - connection_id: c.id, - selected_tools: null, // null = all tools - selected_resources: null, // null = all resources - selected_prompts: null, // null = all prompts - })), - }; - } - // Normal database lookup for string IDs return this.findByIdInternal(this.db, id); } @@ -422,6 +383,8 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { const status: "active" | "inactive" = row.status === "active" ? "active" : "inactive"; + const metadata = this.parseJson<{ instructions?: string }>(row.metadata); + return { id: row.id, organization_id: row.organization_id, @@ -433,7 +396,10 @@ export class VirtualMCPStorage implements VirtualMCPStoragePort { updated_at: updatedAt, created_by: row.created_by, updated_by: undefined, // connections table doesn't have updated_by - metadata: this.parseJson<{ instructions?: string }>(row.metadata), + metadata: { + ...metadata, + instructions: metadata?.instructions ?? null, + }, connections: aggregationRows.map((agg) => ({ connection_id: agg.child_connection_id, selected_tools: this.parseJson(agg.selected_tools), diff --git a/apps/mesh/src/tools/connection/schema.ts b/apps/mesh/src/tools/connection/schema.ts index f97b44a7ea..2c7dd88184 100644 --- a/apps/mesh/src/tools/connection/schema.ts +++ b/apps/mesh/src/tools/connection/schema.ts @@ -1,217 +1,24 @@ /** - * Connection Entity Schema + * Connection Schema Re-exports * - * Single source of truth for connection types. - * Uses snake_case field names matching the database schema directly. - */ - -import { z } from "zod"; - -/** - * OAuth configuration schema for downstream MCP - */ -const OAuthConfigSchema = z.object({ - authorizationEndpoint: z.string().url(), - tokenEndpoint: z.string().url(), - introspectionEndpoint: z.string().url().optional(), - clientId: z.string(), - clientSecret: z.string().optional(), - scopes: z.array(z.string()), - grantType: z.enum(["authorization_code", "client_credentials"]), -}); - -export type OAuthConfig = z.infer; - -/** - * Tool annotations schema from MCP spec - */ -const ToolAnnotationsSchema = z.object({ - title: z.string().optional(), - readOnlyHint: z.boolean().optional(), - destructiveHint: z.boolean().optional(), - idempotentHint: z.boolean().optional(), - openWorldHint: z.boolean().optional(), -}); - -/** - * Tool definition schema from MCP discovery - */ -const ToolDefinitionSchema = z.object({ - name: z.string(), - description: z.string().optional(), - inputSchema: z.record(z.string(), z.unknown()), - outputSchema: z.record(z.string(), z.unknown()).optional(), - annotations: ToolAnnotationsSchema.optional(), - _meta: z.record(z.string(), z.unknown()).optional(), -}); - -export type ToolDefinition = z.infer; - -/** - * Connection parameters - discriminated by connection_type - * - * HTTP/SSE/WebSocket: HTTP headers for requests - * STDIO: Environment variables + command config - */ -const HttpConnectionParametersSchema = z.object({ - headers: z.record(z.string(), z.string()).optional(), -}); - -const StdioConnectionParametersSchema = z.object({ - command: z.string().describe("Command to run (e.g., 'npx', 'node')"), - args: z.array(z.string()).optional().describe("Command arguments"), - cwd: z.string().optional().describe("Working directory"), - envVars: z - .record(z.string(), z.string()) - .optional() - .describe("Environment variables (encrypted in storage)"), -}); - -export type HttpConnectionParameters = z.infer< - typeof HttpConnectionParametersSchema ->; -export type StdioConnectionParameters = z.infer< - typeof StdioConnectionParametersSchema ->; -export type ConnectionParameters = - | HttpConnectionParameters - | StdioConnectionParameters; - -/** - * Connection entity schema - single source of truth. - * Compliant with collections binding pattern. - */ -export const ConnectionEntitySchema = z.object({ - // Base collection entity fields - id: z.string().describe("Unique identifier for the connection"), - title: z.string().describe("Human-readable name for the connection"), - created_at: z.string().describe("When the connection was created"), - updated_at: z.string().describe("When the connection was last updated"), - created_by: z.string().describe("User ID who created the connection"), - updated_by: z - .string() - .optional() - .describe("User ID who last updated the connection"), - - // Connection-specific fields - organization_id: z - .string() - .describe("Organization ID this connection belongs to"), - description: z.string().nullable().describe("Description of the connection"), - icon: z.string().nullable().describe("Icon URL for the connection"), - app_name: z.string().nullable().describe("Associated app name"), - app_id: z.string().nullable().describe("Associated app ID"), - - connection_type: z - .enum(["HTTP", "SSE", "Websocket", "STDIO", "VIRTUAL"]) - .describe("Type of connection"), - connection_url: z - .string() - .nullable() - .describe( - "URL for HTTP/SSE/WebSocket connections. virtual://$id for VIRTUAL. Null for STDIO.", - ), - connection_token: z - .string() - .nullable() - .describe("Authentication token (for HTTP connections)"), - connection_headers: z - .union([StdioConnectionParametersSchema, HttpConnectionParametersSchema]) - .nullable() - .describe( - "Connection parameters. HTTP: { headers }. STDIO: { command, args, cwd, envVars }", - ), - - oauth_config: OAuthConfigSchema.nullable().describe("OAuth configuration"), - - // New configuration fields (snake_case) - configuration_state: z - .record(z.string(), z.unknown()) - .nullable() - .describe("Configuration state (decrypted)"), - configuration_scopes: z - .array(z.string()) - .nullable() - .optional() - .describe("Configuration scopes"), - - metadata: z - .record(z.string(), z.unknown()) - .nullable() - .describe("Additional metadata (includes repository info)"), - tools: z - .array(ToolDefinitionSchema) - .nullable() - .describe("Discovered tools from MCP"), - bindings: z.array(z.string()).nullable().describe("Detected bindings"), - - status: z.enum(["active", "inactive", "error"]).describe("Current status"), -}); - -/** - * The connection entity type - use this everywhere instead of MCPConnection - */ -export type ConnectionEntity = z.infer; - -/** - * Input schema for creating connections - */ -export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({ - created_at: true, - updated_at: true, - created_by: true, - updated_by: true, - organization_id: true, - tools: true, - bindings: true, - status: true, -}).partial({ - id: true, - description: true, - icon: true, - app_name: true, - app_id: true, - connection_url: true, - connection_token: true, - connection_headers: true, - oauth_config: true, - configuration_state: true, - configuration_scopes: true, - metadata: true, -}); - -export type ConnectionCreateData = z.infer; - -/** - * Input schema for updating connections - */ -export const ConnectionUpdateDataSchema = ConnectionEntitySchema.partial(); - -export type ConnectionUpdateData = z.infer; - -/** - * Type guard to check if parameters are STDIO type - */ -export function isStdioParameters( - params: ConnectionParameters | null | undefined, -): params is StdioConnectionParameters { - return !!params && "command" in params; -} - -/** - * Parse virtual MCP ID from virtual:// URL - * @returns The virtual MCP ID or null if not a virtual URL - */ -export function parseVirtualUrl(url: string | null | undefined): string | null { - if (!url || !url.startsWith("virtual://")) { - return null; - } - return url.replace("virtual://", ""); -} - -/** - * Build virtual:// URL from virtual MCP ID - */ -export function buildVirtualUrl(virtualMcpId: string): string { - return `virtual://${virtualMcpId}`; -} + * Re-exports schemas from @decocms/mesh-sdk to maintain a single source of truth. + * This file exists to preserve existing import paths while delegating to the SDK. + */ + +// Re-export all schemas, types, and utility functions from mesh-sdk +export { + ConnectionEntitySchema, + ConnectionCreateDataSchema, + ConnectionUpdateDataSchema, + isStdioParameters, + parseVirtualUrl, + buildVirtualUrl, + type ConnectionEntity, + type ConnectionCreateData, + type ConnectionUpdateData, + type ConnectionParameters, + type HttpConnectionParameters, + type StdioConnectionParameters, + type OAuthConfig, + type ToolDefinition, +} from "@decocms/mesh-sdk/types"; diff --git a/apps/mesh/src/tools/connection/update.ts b/apps/mesh/src/tools/connection/update.ts index ac4c66e72f..efd55fa5fd 100644 --- a/apps/mesh/src/tools/connection/update.ts +++ b/apps/mesh/src/tools/connection/update.ts @@ -231,16 +231,34 @@ export const COLLECTION_CONNECTIONS_UPDATE = defineTool({ }; const connection = await ctx.storage.connections.update(id, updatePayload); - // Invoke ON_MCP_CONFIGURATION callback if configuration was updated - // Ignore errors but await for the response before responding - if ( - (data.configuration_state !== undefined || - data.configuration_scopes !== undefined) && - finalState && - finalScopes.length > 0 - ) { - try { - await using proxy = await ctx.createMCPProxy(id); + // Invoke ON_MCP_CONFIGURATION callback to trigger onChange handlers + // If no scopes saved, fetch from MCP_CONFIGURATION first + try { + await using proxy = await ctx.createMCPProxy(id); + + // If no scopes, try to fetch from MCP_CONFIGURATION + if (finalScopes.length === 0) { + const mcpConfig = (await proxy.callTool({ + name: "MCP_CONFIGURATION", + arguments: {}, + })) as { scopes?: string[] }; + + if (mcpConfig?.scopes && mcpConfig.scopes.length > 0) { + finalScopes = mcpConfig.scopes; + // Update connection with fetched scopes + await ctx.storage.connections.update(id, { + configuration_scopes: finalScopes, + }); + } + } + + // Initialize empty state if we have scopes but no state + if (finalScopes.length > 0 && !finalState) { + finalState = {}; + } + + // Call ON_MCP_CONFIGURATION if we have state and scopes + if (finalState && finalScopes.length > 0) { await proxy.callTool({ name: "ON_MCP_CONFIGURATION", arguments: { @@ -248,10 +266,11 @@ export const COLLECTION_CONNECTIONS_UPDATE = defineTool({ scopes: finalScopes, }, }); - await proxy.close().catch(console.error); - } catch (error) { - console.error("Failed to invoke ON_MCP_CONFIGURATION callback", error); } + + await proxy.close().catch(console.error); + } catch (error) { + console.error("Failed to invoke ON_MCP_CONFIGURATION callback", error); } return { diff --git a/apps/mesh/src/tools/virtual/schema.ts b/apps/mesh/src/tools/virtual/schema.ts index ce29735418..aed78c9ffb 100644 --- a/apps/mesh/src/tools/virtual/schema.ts +++ b/apps/mesh/src/tools/virtual/schema.ts @@ -1,199 +1,17 @@ /** - * Virtual MCP Entity Schema + * Virtual MCP Schema Re-exports * - * Single source of truth for virtual MCP types. - * Uses snake_case field names matching the database schema directly. + * Re-exports schemas from @decocms/mesh-sdk to maintain a single source of truth. + * This file exists to preserve existing import paths while delegating to the SDK. */ -import { z } from "zod"; - -/** - * Virtual MCP connection schema - defines which connection and tools/resources/prompts are included - */ -const VirtualMCPConnectionSchema = z.object({ - connection_id: z.string().describe("Connection ID"), - selected_tools: z - .array(z.string()) - .nullable() - .describe( - "Selected tool names. null = all tools included, array = only these tools included", - ), - selected_resources: z - .array(z.string()) - .nullable() - .describe( - "Selected resource URIs or patterns. Supports * and ** wildcards for pattern matching. null = all resources included, array = only these resources included", - ), - selected_prompts: z - .array(z.string()) - .nullable() - .describe( - "Selected prompt names. null = all prompts included, array = only these prompts included", - ), -}); - -export type VirtualMCPConnection = z.infer; - -/** - * Virtual MCP entity schema - single source of truth - * Compliant with collections binding pattern - */ -export const VirtualMCPEntitySchema = z.object({ - // Base collection entity fields - id: z - .string() - .nullable() - .describe( - "Unique identifier for the virtual MCP (null for synthetic Decopilot agent)", - ), - title: z.string().describe("Human-readable name for the virtual MCP"), - description: z.string().nullable().describe("Description of the virtual MCP"), - icon: z - .string() - .nullable() - .optional() - .describe("Icon URL for the virtual MCP"), - created_at: z.string().describe("When the virtual MCP was created"), - updated_at: z.string().describe("When the virtual MCP was last updated"), - created_by: z.string().describe("User ID who created the virtual MCP"), - updated_by: z - .string() - .optional() - .describe("User ID who last updated the virtual MCP"), - - // Virtual MCP-specific fields - organization_id: z - .string() - .describe("Organization ID this virtual MCP belongs to"), - status: z.enum(["active", "inactive"]).describe("Current status"), - // Metadata (stored in connections.metadata) - metadata: z - .object({ - instructions: z.string().optional().describe("MCP server instructions"), - }) - .nullable() - .optional() - .describe("Additional metadata including MCP server instructions"), - // Nested connections - connections: z - .array(VirtualMCPConnectionSchema) - .describe("Connections with their selected tools, resources, and prompts"), -}); - -/** - * The virtual MCP entity type - */ -export type VirtualMCPEntity = z.infer; - -/** - * Input schema for creating virtual MCPs - */ -export const VirtualMCPCreateDataSchema = z.object({ - title: z.string().min(1).max(255).describe("Name for the virtual MCP"), - description: z - .string() - .nullable() - .optional() - .describe("Optional description"), - icon: z.string().nullable().optional().describe("Optional icon URL"), - status: z - .enum(["active", "inactive"]) - .optional() - .default("active") - .describe("Initial status"), - metadata: z - .object({ - instructions: z.string().optional().describe("MCP server instructions"), - }) - .nullable() - .optional() - .describe("Additional metadata including MCP server instructions"), - connections: z - .array( - z.object({ - connection_id: z.string().describe("Connection ID"), - selected_tools: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected tool names (null/undefined = all tools included)", - ), - selected_resources: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected resource URIs or patterns with * and ** wildcards (null/undefined = all resources included)", - ), - selected_prompts: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected prompt names (null/undefined = all prompts included)", - ), - }), - ) - .describe( - "Connections to include with their selected tools/resources/prompts", - ), -}); - -export type VirtualMCPCreateData = z.infer; - -/** - * Input schema for updating virtual MCPs - */ -export const VirtualMCPUpdateDataSchema = z.object({ - title: z.string().min(1).max(255).optional().describe("New name"), - description: z - .string() - .nullable() - .optional() - .describe("New description (null to clear)"), - icon: z - .string() - .nullable() - .optional() - .describe("New icon URL (null to clear)"), - status: z.enum(["active", "inactive"]).optional().describe("New status"), - metadata: z - .object({ - instructions: z.string().optional().describe("MCP server instructions"), - }) - .nullable() - .optional() - .describe("Additional metadata including MCP server instructions"), - connections: z - .array( - z.object({ - connection_id: z.string().describe("Connection ID"), - selected_tools: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected tool names (null/undefined = all tools included)", - ), - selected_resources: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected resource URIs or patterns with * and ** wildcards (null/undefined = all resources included)", - ), - selected_prompts: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Selected prompt names (null/undefined = all prompts included)", - ), - }), - ) - .optional() - .describe("New connections (replaces existing)"), -}); - -export type VirtualMCPUpdateData = z.infer; +// Re-export all schemas and types from mesh-sdk +export { + VirtualMCPEntitySchema, + VirtualMCPCreateDataSchema, + VirtualMCPUpdateDataSchema, + type VirtualMCPEntity, + type VirtualMCPCreateData, + type VirtualMCPUpdateData, + type VirtualMCPConnection, +} from "@decocms/mesh-sdk/types"; diff --git a/apps/mesh/src/web/components/binding-collection-view.tsx b/apps/mesh/src/web/components/binding-collection-view.tsx index 47184b580b..4af29bf9d9 100644 --- a/apps/mesh/src/web/components/binding-collection-view.tsx +++ b/apps/mesh/src/web/components/binding-collection-view.tsx @@ -1,6 +1,12 @@ import { CollectionTab } from "@/web/components/details/connection/collection-tab"; import { BindingCollectionEmptyState } from "@/web/components/binding-collection-empty-state"; -import { CollectionHeader } from "@/web/components/collections/collection-header"; +import { Page } from "@/web/components/page"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from "@deco/ui/components/breadcrumb.tsx"; import { useBindingConnections, useCollectionBindings, @@ -62,10 +68,20 @@ export function BindingCollectionView({ connection && activeCollection && (connection.id || installedConnectionId); return ( -
- + + + + + + + {title} + + + + + -
+ @@ -99,7 +115,7 @@ export function BindingCollectionView({
)} -
- + + ); } diff --git a/apps/mesh/src/web/components/chat/context.tsx b/apps/mesh/src/web/components/chat/context.tsx index 74beee61b5..254833d978 100644 --- a/apps/mesh/src/web/components/chat/context.tsx +++ b/apps/mesh/src/web/components/chat/context.tsx @@ -43,6 +43,7 @@ import { useInvalidateCollectionsOnToolCall } from "../../hooks/use-invalidate-c import { useLocalStorage } from "../../hooks/use-local-storage"; import { authClient } from "../../lib/auth-client"; import { LOCALSTORAGE_KEYS } from "../../lib/localstorage-keys"; +import type { ToolSelectionStrategy } from "@/mcp-clients/virtual-mcp/types"; import type { ChatMessage } from "./index"; import { type ModelChangePayload, @@ -112,6 +113,10 @@ interface ChatContextValue { selectedModel: SelectedModelState | null; setSelectedModel: (model: ModelChangePayload) => void; + // Mode state + selectedMode: ToolSelectionStrategy; + setSelectedMode: (mode: ToolSelectionStrategy) => void; + // Chat state messages: ChatMessage[]; chatStatus: "submitted" | "streaming" | "ready" | "error"; @@ -552,6 +557,14 @@ export function ChatProvider({ children }: PropsWithChildren) { // Model state const modelsConnections = useModelConnections(); const [selectedModel, setModel] = useModelState(locator, modelsConnections); + + // Mode state + const [selectedMode, setSelectedMode] = + useLocalStorage( + LOCALSTORAGE_KEYS.chatSelectedMode(locator), + "code_execution", + ); + // Always fetch messages for the active thread - if it's truly new, the query returns empty const initialMessages = useThreadMessages(activeThreadId); @@ -694,6 +707,7 @@ export function ChatProvider({ children }: PropsWithChildren) { thread_id: activeThreadId, agent: { id: selectedVirtualMcp?.id ?? decopilotId, + mode: selectedMode, }, user: { avatar: user?.image ?? undefined, @@ -764,6 +778,10 @@ export function ChatProvider({ children }: PropsWithChildren) { selectedModel, setSelectedModel, + // Mode state + selectedMode, + setSelectedMode, + // Chat session state messages: chat.messages, chatStatus: chat.status, diff --git a/apps/mesh/src/web/components/chat/index.tsx b/apps/mesh/src/web/components/chat/index.tsx index 58d85f11d4..5ab4d5acba 100644 --- a/apps/mesh/src/web/components/chat/index.tsx +++ b/apps/mesh/src/web/components/chat/index.tsx @@ -2,19 +2,7 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import { cn } from "@deco/ui/lib/utils.ts"; import { X } from "@untitledui/icons"; import type { UIMessage } from "ai"; -import type { - PropsWithChildren, - ReactElement, - ReactNode, - RefObject, -} from "react"; -import { - Children, - isValidElement, - useEffect, - useRef, - useTransition, -} from "react"; +import type { PropsWithChildren, ReactNode } from "react"; import { ChatProvider, useChat } from "./context"; import { IceBreakers } from "./ice-breakers"; import { ChatInput } from "./input"; @@ -27,108 +15,33 @@ export { useChat } from "./context"; export { ModelSelector } from "./select-model"; export type { ModelChangePayload, SelectedModelState } from "./select-model"; export type { VirtualMCPInfo } from "./select-virtual-mcp"; +export type { ToolSelectionStrategy } from "@/mcp-clients/virtual-mcp/types"; export type ChatMessage = UIMessage; export type ChatStatus = UseChatHelpers>["status"]; -function useChatAutoScroll({ - messageCount, - chatStatus, - sentinelRef, -}: { - messageCount: number; - chatStatus: ChatStatus; - sentinelRef: RefObject; -}) { - const [_, startTransition] = useTransition(); - - // Periodic scrolling during streaming (low priority) - // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Interval lifecycle management requires useEffect - useEffect(() => { - if (chatStatus !== "streaming") { - return; - } - - const intervalId = setInterval(() => { - startTransition(() => { - sentinelRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }); - }, 500); - - return () => { - clearInterval(intervalId); - }; - }, [chatStatus, sentinelRef]); - - // Scroll to the sentinel when the message count changes - // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Interval lifecycle management requires useEffect - useEffect(() => { - startTransition(() => { - sentinelRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }); - }, [messageCount, sentinelRef]); -} - -function findChild( - children: ReactNode, - type: (props: T) => ReactNode, -): ReactElement | null { - const arr = Children.toArray(children); - for (const child of arr) { - if (isValidElement(child) && child.type === type) { - return child as ReactElement; - } - } - return null; -} - function ChatRoot({ className, children, }: PropsWithChildren<{ className?: string }>) { + // Detect if className contains bg-background + const hasBackgroundClass = className?.includes("bg-background"); + const surfaceBg = hasBackgroundClass ? "var(--background)" : "var(--muted)"; + return (
{children}
); } -function ChatHeader({ children }: PropsWithChildren) { - const left = findChild(children, ChatHeaderLeft); - const right = findChild(children, ChatHeaderRight); - - return ( -
- {left} - {right} -
- ); -} - -function ChatHeaderLeft({ children }: PropsWithChildren) { - return ( -
- {children} -
- ); -} - -function ChatHeaderRight({ children }: PropsWithChildren) { - return
{children}
; -} - function ChatMain({ children, className, @@ -148,17 +61,10 @@ function ChatEmptyState({ children }: PropsWithChildren) { ); } -function ChatMessages({ minHeightOffset = 240 }: { minHeightOffset?: number }) { +function ChatMessages() { const { messages, chatStatus: status } = useChat(); - const sentinelRef = useRef(null); const messagePairs = useMessagePairs(messages); - useChatAutoScroll({ - messageCount: messagePairs.length, - chatStatus: status, - sentinelRef, - }); - return (
@@ -167,11 +73,9 @@ function ChatMessages({ minHeightOffset = 240 }: { minHeightOffset?: number }) { key={`pair-${pair.user.id}`} pair={pair} isLastPair={index === messagePairs.length - 1} - minHeightOffset={minHeightOffset} status={index === messagePairs.length - 1 ? status : undefined} /> ))} -
); @@ -276,10 +180,6 @@ export function ChatHighlight({ } export const Chat = Object.assign(ChatRoot, { - Header: Object.assign(ChatHeader, { - Left: ChatHeaderLeft, - Right: ChatHeaderRight, - }), Main: ChatMain, Messages: ChatMessages, EmptyState: ChatEmptyState, diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index bc608d935a..66bc43d42c 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -1,5 +1,4 @@ import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; -import { isDecopilot, useProjectContext } from "@decocms/mesh-sdk"; import { getAgentColor } from "@/web/utils/agent-color"; import { Button } from "@deco/ui/components/button.tsx"; import { @@ -7,37 +6,124 @@ import { PopoverContent, PopoverTrigger, } from "@deco/ui/components/popover.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; +import { + getWellKnownDecopilotVirtualMCP, + isDecopilot, + useProjectContext, +} from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; import { AlertCircle, AlertTriangle, ArrowUp, ChevronDown, - Users03, Edit01, Stop, + Users03, XCircle, } from "@untitledui/icons"; import type { FormEvent } from "react"; import { useEffect, useRef, useState, type MouseEvent } from "react"; import { useChat } from "./context"; -import { isTiptapDocEmpty } from "./tiptap/utils"; import { ChatHighlight } from "./index"; +import { ModeSelector } from "./select-mode"; +import { ModelSelector } from "./select-model"; import { VirtualMCPPopoverContent, VirtualMCPSelector, type VirtualMCPInfo, } from "./select-virtual-mcp"; -import { ModelSelector } from "./select-model"; +import { FileUploadButton } from "./tiptap/file"; import { - TiptapProvider, TiptapInput, + TiptapProvider, type TiptapInputHandle, } from "./tiptap/input"; -import { FileUploadButton } from "./tiptap/file"; +import { isTiptapDocEmpty } from "./tiptap/utils"; import { UsageStats } from "./usage-stats"; +// ============================================================================ +// DecopilotIconButton - Icon button for Decopilot (similar to FileUploadButton) +// ============================================================================ + +interface DecopilotIconButtonProps { + onVirtualMcpChange: (virtualMcpId: string | null) => void; + virtualMcps: VirtualMCPInfo[]; + disabled?: boolean; +} + +function DecopilotIconButton({ + onVirtualMcpChange, + virtualMcps, + disabled = false, +}: DecopilotIconButtonProps) { + const [open, setOpen] = useState(false); + const searchInputRef = useRef(null); + const { org } = useProjectContext(); + + const decopilot = getWellKnownDecopilotVirtualMCP(org.id); + + // Filter out Decopilot from the list + const filteredVirtualMcps = virtualMcps.filter( + (virtualMcp) => !virtualMcp.id || !isDecopilot(virtualMcp.id), + ); + + // Focus search input when popover opens + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + if (open) { + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } + }, [open]); + + const handleVirtualMcpChange = (virtualMcpId: string | null) => { + onVirtualMcpChange(virtualMcpId); + setOpen(false); + }; + + return ( + + + + + + + + {!open && Decopilot} + + + + + + ); +} + // ============================================================================ // VirtualMCPBadge - Internal component for displaying selected virtual MCP // ============================================================================ @@ -189,6 +275,8 @@ export function ChatInput() { modelsConnections, selectedModel, setSelectedModel, + selectedMode, + setSelectedMode, messages, isStreaming, sendMessage, @@ -302,16 +390,14 @@ export function ChatInput() { )} > {/* Virtual MCP Badge Header */} - {selectedVirtualMcp && - selectedVirtualMcp.id && - !isDecopilot(selectedVirtualMcp.id) && ( - - )} + {selectedVirtualMcp?.id && !isDecopilot(selectedVirtualMcp.id) && ( + + )} {/* Inner container with the input */}
@@ -342,18 +428,29 @@ export function ChatInput() { {/* Bottom Actions Row */}
- {/* Left Actions (selectors) */} + {/* Left Actions (agent selector and usage stats) */}
- {/* VirtualMCPSelector only shown when default is selected (no badge) */} - {!selectedVirtualMcp && ( + {/* Always show selector button - DecopilotIconButton for Decopilot, VirtualMCPSelector for others */} + {selectedVirtualMcp && isDecopilot(selectedVirtualMcp.id) ? ( + + ) : ( )} + +
+ + {/* Right Actions (model, mode, file upload, send button) */} +
- -
- - {/* Right Actions (send button) */} -
+ (null); - // Parts are already filtered to reasoning parts - const isReasoningStreaming = parts.some((part) => part.state === "streaming"); + // Auto-scroll within the thought summary container when new parts arrive + // Uses scrollTop instead of scrollIntoView to avoid conflicts with parent scrolling + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Content change tracking requires useEffect + useEffect(() => { + if (isStreaming && scrollContainerRef.current) { + // Scroll to bottom of the internal container + scrollContainerRef.current.scrollTop = + scrollContainerRef.current.scrollHeight; + } + }, [parts, isStreaming]); - // Auto-expand when reasoning is streaming - const shouldShowContent = isReasoningStreaming || isExpanded; + // Always expanded while streaming, collapsible when done + const shouldShowContent = isStreaming || isExpanded; return (
{shouldShowContent && ( -
- {parts.map((part, index) => ( -
- -
- ))} +
+ {/* Gradient overlay - only while streaming */} + {isStreaming && ( +
+ )} +
+ {parts.map((part, index) => { + return ( +
+ +
+ ); + })} +
)}
@@ -161,6 +186,7 @@ interface MessageAssistantProps { message: UIMessage | null; status?: "streaming" | "submitted" | "ready" | "error"; className?: string; + isLast: boolean; } interface MessagePartProps { @@ -270,6 +296,7 @@ export function MessageAssistant({ message, status, className, + isLast = false, }: MessageAssistantProps) { const isStreaming = status === "streaming"; const isSubmitted = status === "submitted"; @@ -303,10 +330,11 @@ export function MessageAssistant({ duration={duration} parts={reasoningParts} id={message.id} + isStreaming={isStreaming} /> )} {message.parts.map((part, index) => { - const isLast = index === message.parts.length - 1; + const isLastPart = index === message.parts.length - 1; const nextPart = message.parts[index + 1]; const prevPart = message.parts[index - 1]; @@ -323,7 +351,7 @@ export function MessageAssistant({ key={`${message.id}-${index}`} part={part} id={message.id} - usageStats={isLast && } + usageStats={isLastPart && } isFollowedByToolCall={nextIsToolCall} isFirstToolCallInSequence={isFirstToolCallInSequence} isLastToolCallInSequence={isLastToolCallInSequence} @@ -337,6 +365,8 @@ export function MessageAssistant({ ) : ( )} + {/* Smart auto-scroll sentinel - only rendered for the last message during streaming */} + {isLast && isStreaming && } ); } diff --git a/apps/mesh/src/web/components/chat/message/pair.tsx b/apps/mesh/src/web/components/chat/message/pair.tsx index dd0e177f3f..37b6dd96e1 100644 --- a/apps/mesh/src/web/components/chat/message/pair.tsx +++ b/apps/mesh/src/web/components/chat/message/pair.tsx @@ -57,16 +57,10 @@ export function useMessagePairs( interface MessagePairProps { pair: MessagePair; isLastPair: boolean; - minHeightOffset?: number; status?: ChatStatus; } -export function MessagePair({ - pair, - isLastPair, - minHeightOffset, - status, -}: MessagePairProps) { +export function MessagePair({ pair, isLastPair, status }: MessagePairProps) { const pairRef = useRef(null); const scrollToPair = () => { @@ -78,23 +72,34 @@ export function MessagePair({ } }; + const handlePairRef = (node: HTMLDivElement | null) => { + pairRef.current = node; + + if (isLastPair) { + node?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + return (
{/* Sticky overlay to prevent scrolling content from appearing above the user message */} -
+
{/* Single MessageAssistant - handles all states internally */} - +
); } diff --git a/apps/mesh/src/web/components/chat/message/smart-auto-scroll.tsx b/apps/mesh/src/web/components/chat/message/smart-auto-scroll.tsx new file mode 100644 index 0000000000..598d0ce7aa --- /dev/null +++ b/apps/mesh/src/web/components/chat/message/smart-auto-scroll.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Smart auto-scroll sentinel component that handles auto-scrolling when visible. + * Uses IntersectionObserver to detect when the user has scrolled away, automatically + * disabling auto-scroll until they scroll back. + * + * This component should be rendered at the end of the last message content during streaming. + */ +export function SmartAutoScroll({ + parts, +}: { + parts: unknown[] | null | undefined; +}) { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + // Extract content dependencies from parts for dependency tracking + const partsLength = parts?.length; + const lastPart = parts?.[parts.length - 1]; + + // Helper function to find and scroll the scrollable parent container + const scrollToBottom = () => { + if (!ref.current) { + return; + } + + // Find the scrollable parent container + let scrollContainer: HTMLElement | null = ref.current.parentElement; + while (scrollContainer) { + const style = window.getComputedStyle(scrollContainer); + if ( + style.overflowY === "auto" || + style.overflowY === "scroll" || + style.overflow === "auto" || + style.overflow === "scroll" + ) { + // Found the scrollable container, scroll to bottom + scrollContainer.scrollTop = scrollContainer.scrollHeight; + return; + } + scrollContainer = scrollContainer.parentElement; + } + }; + + // Set up IntersectionObserver to track visibility + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Observer lifecycle management requires useEffect + useEffect(() => { + if (!ref.current) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + // Component is visible if any part of it (10% threshold) is in viewport + const isIntersecting = entries[0]?.isIntersecting ?? false; + setIsVisible(isIntersecting); + }, + { + threshold: 0.1, // Trigger when 10% of component is visible + rootMargin: "0px", + }, + ); + + observer.observe(ref.current); + + return () => { + observer.disconnect(); + }; + }, []); + + // Periodic scrolling during streaming (only when visible) + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Interval lifecycle management requires useEffect + useEffect(() => { + if (!isVisible) { + return; + } + + const intervalId = setInterval(() => { + scrollToBottom(); + }, 500); + + return () => { + clearInterval(intervalId); + }; + }, [isVisible]); + + // Scroll when content changes (only when visible) + // oxlint-disable-next-line ban-use-effect/ban-use-effect -- Content change tracking requires useEffect + useEffect(() => { + if (!isVisible) { + return; + } + + scrollToBottom(); + }, [isVisible, partsLength, lastPart]); + + return
; +} diff --git a/apps/mesh/src/web/components/chat/select-mode.tsx b/apps/mesh/src/web/components/chat/select-mode.tsx new file mode 100644 index 0000000000..989ca42bb2 --- /dev/null +++ b/apps/mesh/src/web/components/chat/select-mode.tsx @@ -0,0 +1,219 @@ +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@deco/ui/components/tooltip.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import type { ToolSelectionStrategy } from "@/mcp-clients/virtual-mcp/types"; +import { + ArrowsRight, + Check, + ChevronDown, + Code01, + Lightbulb02, +} from "@untitledui/icons"; +import { useState } from "react"; + +/** + * Mode configuration with business-friendly labels and descriptions + */ +const MODE_CONFIGS: Record< + ToolSelectionStrategy, + { + label: string; + description: string; + icon: typeof ArrowsRight; + recommended?: boolean; + } +> = { + passthrough: { + label: "Direct access", + description: "Best for small teams or when you need predictable behavior", + icon: ArrowsRight, + }, + smart_tool_selection: { + label: "Smart discovery", + description: + "Ideal for large teams with many tools - AI finds what it needs", + icon: Lightbulb02, + }, + code_execution: { + label: "Smart execution", + description: "Maximum flexibility - AI can write code to orchestrate tools", + icon: Code01, + recommended: true, + }, +}; + +function ModeItemContent({ + mode, + isSelected, + onSelect, +}: { + mode: ToolSelectionStrategy; + isSelected?: boolean; + onSelect: () => void; +}) { + const config = MODE_CONFIGS[mode]; + const Icon = config.icon; + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Text Content */} +
+
+ + {config.label} + + {config.recommended && ( + + Recommended + + )} + {isSelected && ( + + )} +
+

+ {config.description} +

+
+
+ ); +} + +function SelectedModeDisplay({ + mode, + placeholder = "Select mode", +}: { + mode: ToolSelectionStrategy | undefined; + placeholder?: string; +}) { + if (!mode) { + return ( +
+ {placeholder} + +
+ ); + } + + const config = MODE_CONFIGS[mode]; + const Icon = config.icon; + + return ( +
+ + + {config.label} + + +
+ ); +} + +export interface ModeSelectorProps { + selectedMode: ToolSelectionStrategy; + onModeChange: (mode: ToolSelectionStrategy) => void; + variant?: "borderless" | "bordered"; + className?: string; + placeholder?: string; + disabled?: boolean; +} + +/** + * Mode selector component for choosing agent execution mode. + * Displays business-friendly labels consistent with the share modal. + */ +export function ModeSelector({ + selectedMode, + onModeChange, + variant = "borderless", + className, + placeholder = "Mode", + disabled = false, +}: ModeSelectorProps) { + const [open, setOpen] = useState(false); + + const handleModeChange = (mode: ToolSelectionStrategy) => { + onModeChange(mode); + setOpen(false); + }; + + return ( + + + + + + + + + + {MODE_CONFIGS[selectedMode]?.description ?? "Choose agent mode"} + + + + +
+ {(Object.keys(MODE_CONFIGS) as ToolSelectionStrategy[]).map( + (mode) => ( + handleModeChange(mode)} + /> + ), + )} +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index 41b19c721f..272923016d 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -349,7 +349,7 @@ function SelectedModelDisplay({ } return ( -
+
{model.logo && ( {model.title} )} - + {model.title}
); diff --git a/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx b/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx index 63c1a4e1c9..ba38d66764 100644 --- a/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx +++ b/apps/mesh/src/web/components/chat/select-virtual-mcp.tsx @@ -14,12 +14,11 @@ import { } from "@deco/ui/components/tooltip.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { - getWellKnownDecopilotVirtualMCP, - useProjectContext, + isDecopilot, useVirtualMCPs, type VirtualMCPEntity, } from "@decocms/mesh-sdk"; -import { Check, ChevronDown, SearchMd, Users03 } from "@untitledui/icons"; +import { Check, SearchMd, Users03 } from "@untitledui/icons"; import { useEffect, useRef, @@ -108,12 +107,17 @@ export function VirtualMCPPopoverContent({ navigateOnCreate: true, }); - // Filter virtual MCPs based on search term + // Filter virtual MCPs based on search term and exclude Decopilot const filteredVirtualMcps = (() => { - if (!searchTerm.trim()) return virtualMcps; + // First filter out Decopilot + const nonDecopilotMcps = virtualMcps.filter( + (virtualMcp) => !virtualMcp.id || !isDecopilot(virtualMcp.id), + ); + + if (!searchTerm.trim()) return nonDecopilotMcps; const search = searchTerm.toLowerCase(); - return virtualMcps.filter((virtualMcp) => { + return nonDecopilotMcps.filter((virtualMcp) => { return ( virtualMcp.title.toLowerCase().includes(search) || virtualMcp.description?.toLowerCase().includes(search) @@ -220,21 +224,20 @@ export function VirtualMCPSelector({ }: VirtualMCPSelectorProps) { const [open, setOpen] = useState(false); const searchInputRef = useRef(null); - const { org } = useProjectContext(); // Use provided virtual MCPs or fetch from hook const virtualMcpsFromHook = useVirtualMCPs(); - const virtualMcps = virtualMcpsProp ?? virtualMcpsFromHook; + const allVirtualMcps = virtualMcpsProp ?? virtualMcpsFromHook; - // Get default Decopilot agent info - const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); + // Filter out Decopilot from the list + const virtualMcps = allVirtualMcps.filter( + (virtualMcp) => !virtualMcp.id || !isDecopilot(virtualMcp.id), + ); const selectedVirtualMcp = selectedVirtualMcpId - ? virtualMcps.find((g) => g.id === selectedVirtualMcpId) + ? allVirtualMcps.find((g) => g.id === selectedVirtualMcpId) : null; - const selected = selectedVirtualMcp ?? defaultAgent; - const handleVirtualMcpChange = (virtualMcpId: string | null) => { onVirtualMcpChange(virtualMcpId); setOpen(false); @@ -268,7 +271,7 @@ export function VirtualMCPSelector({ type="button" disabled={disabled} className={cn( - "flex items-center gap-1.5 px-1.5 py-1 rounded-md transition-colors shrink-0", + "flex items-center justify-center size-8 rounded-full transition-colors shrink-0", disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent", @@ -276,20 +279,17 @@ export function VirtualMCPSelector({ )} aria-label={placeholder} > - } - className="rounded-md shrink-0 aspect-square" - /> - - {selected.title} - - + {selectedVirtualMcp ? ( + } + className="rounded-md shrink-0 aspect-square" + /> + ) : ( + + )} diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index 4fba1e06b7..722fa6fb82 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -1,12 +1,13 @@ import { IntegrationIcon } from "@/web/components/integration-icon"; +import { Page } from "@/web/components/page"; import { useDecoChatOpen } from "@/web/hooks/use-deco-chat-open"; import { cn } from "@deco/ui/lib/utils.ts"; import { getWellKnownDecopilotVirtualMCP, useProjectContext, } from "@decocms/mesh-sdk"; -import { ClockRewind, Users03, Plus, X } from "@untitledui/icons"; -import { Suspense, useState } from "react"; +import { ClockRewind, Plus, Users03, X } from "@untitledui/icons"; +import { Suspense, useState, useTransition } from "react"; import { ErrorBoundary } from "../error-boundary"; import { Chat, useChat } from "./index"; import { ThreadsView } from "./threads-sidebar"; @@ -25,11 +26,18 @@ function ChatPanelContent() { } = useChat(); const activeThread = threads.find((thread) => thread.id === activeThreadId); const [showThreadsOverlay, setShowThreadsOverlay] = useState(false); + const [isPending, startTransition] = useTransition(); // Use Decopilot as default agent const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); const displayAgent = selectedVirtualMcp ?? defaultAgent; + const handleNewThread = () => { + startTransition(() => { + setActiveThreadId(crypto.randomUUID()); + }); + }; + if (modelsConnections.length === 0) { const title = "No model provider connected"; const description = @@ -37,17 +45,11 @@ function ChatPanelContent() { return ( - - - + + {displayAgent.title} - - + + - - + + @@ -80,20 +82,16 @@ function ChatPanelContent() { {/* Chat view */}
- - - + + {!isChatEmpty && activeThread?.title ? ( )} - - + + - - + + {isChatEmpty ? ( @@ -166,7 +165,7 @@ function ChatPanelContent() {
) : ( - + )}
@@ -199,9 +198,7 @@ export function ChatPanel() { return ( }> }> - - - + ); diff --git a/apps/mesh/src/web/components/chat/skeleton.tsx b/apps/mesh/src/web/components/chat/skeleton.tsx index ebd0d87cca..b94d67f829 100644 --- a/apps/mesh/src/web/components/chat/skeleton.tsx +++ b/apps/mesh/src/web/components/chat/skeleton.tsx @@ -1,8 +1,8 @@ import { cn } from "@deco/ui/lib/utils.ts"; -export function DecoChatSkeleton() { +export function DecoChatSkeleton({ className }: { className?: string }) { return ( -
+
{/* Header skeleton */}
diff --git a/apps/mesh/src/web/components/chat/tiptap/file/uploader.tsx b/apps/mesh/src/web/components/chat/tiptap/file/uploader.tsx index c62dec731f..14271bf624 100644 --- a/apps/mesh/src/web/components/chat/tiptap/file/uploader.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/file/uploader.tsx @@ -7,7 +7,7 @@ import { import { Plugin, PluginKey } from "@tiptap/pm/state"; import { useCurrentEditor, type Editor } from "@tiptap/react"; import { useEffect, useRef, type ChangeEvent } from "react"; -import { Plus } from "@untitledui/icons"; +import { Attachment01 } from "@untitledui/icons"; import { toast } from "sonner"; import { modelSupportsFiles, @@ -225,7 +225,7 @@ export function FileUploadButton({ disabled={isStreaming || !modelSupportsFilesValue} onClick={() => fileInputRef.current?.click()} > - + diff --git a/apps/mesh/src/web/components/chat/tiptap/input.tsx b/apps/mesh/src/web/components/chat/tiptap/input.tsx index c6ace74d8f..6a5ffb51a5 100644 --- a/apps/mesh/src/web/components/chat/tiptap/input.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/input.tsx @@ -1,5 +1,6 @@ import { cn } from "@deco/ui/lib/utils.ts"; import Placeholder from "@tiptap/extension-placeholder"; +import type { EditorView } from "@tiptap/pm/view"; import { EditorContent, EditorContext, @@ -7,11 +8,10 @@ import { useEditor, } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; -import type { EditorView } from "@tiptap/pm/view"; import type { Ref } from "react"; -import { useEffect, useImperativeHandle, useRef } from "react"; -import type { VirtualMCPInfo } from "../select-virtual-mcp"; +import { Suspense, useEffect, useImperativeHandle, useRef } from "react"; import type { SelectedModelState } from "../select-model"; +import type { VirtualMCPInfo } from "../select-virtual-mcp"; import type { Metadata } from "../types.ts"; import { FileNode, FileUploader } from "./file"; import { MentionNode } from "./mention"; @@ -184,10 +184,14 @@ export function TiptapInput({ /> {/* Render prompts dropdown menu (includes dialog) */} - + + + {/* Render resources dropdown menu */} - + + + {/* Render file upload handler */} diff --git a/apps/mesh/src/web/components/chat/types.ts b/apps/mesh/src/web/components/chat/types.ts index 365052c488..124f264073 100644 --- a/apps/mesh/src/web/components/chat/types.ts +++ b/apps/mesh/src/web/components/chat/types.ts @@ -23,6 +23,7 @@ export interface ChatModelConfig { export interface ChatAgentConfig { id: string | null; + mode: "passthrough" | "smart_tool_selection" | "code_execution"; } export interface ChatUserConfig { diff --git a/apps/mesh/src/web/components/collections/collection-header.tsx b/apps/mesh/src/web/components/collections/collection-header.tsx deleted file mode 100644 index 1a5895613e..0000000000 --- a/apps/mesh/src/web/components/collections/collection-header.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { CollectionDisplayButton } from "./collection-display-button.tsx"; -import type { ReactNode } from "react"; - -interface CollectionHeaderProps { - title: ReactNode; - viewMode?: "table" | "cards"; - onViewModeChange?: (mode: "table" | "cards") => void; - sortKey?: string; - sortDirection?: "asc" | "desc" | null; - onSort?: (key: string) => void; - sortOptions?: Array<{ id: string; label: string }>; - ctaButton?: ReactNode; - leftElement?: ReactNode; -} - -export function CollectionHeader({ - title, - viewMode, - onViewModeChange, - sortKey, - sortDirection, - onSort, - sortOptions = [], - ctaButton, - leftElement, -}: CollectionHeaderProps) { - return ( -
-
-
- {leftElement} - {typeof title === "string" ? ( -

{title}

- ) : ( - title - )} -
-
- {viewMode && onViewModeChange && ( - - )} - {ctaButton} -
-
-
- ); -} diff --git a/apps/mesh/src/web/components/collections/collection-page.tsx b/apps/mesh/src/web/components/collections/collection-page.tsx deleted file mode 100644 index 09bf71cfc8..0000000000 --- a/apps/mesh/src/web/components/collections/collection-page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from "react"; - -interface CollectionPageProps { - children: ReactNode; -} - -export function CollectionPage({ children }: CollectionPageProps) { - return ( -
- {children} -
- ); -} diff --git a/apps/mesh/src/web/components/collections/collection-search.tsx b/apps/mesh/src/web/components/collections/collection-search.tsx index 0815c73530..66b5d6c03f 100644 --- a/apps/mesh/src/web/components/collections/collection-search.tsx +++ b/apps/mesh/src/web/components/collections/collection-search.tsx @@ -10,6 +10,7 @@ interface CollectionSearchProps { className?: string; /** Show a subtle loading spinner when searching in background */ isSearching?: boolean; + disabled?: boolean; } /** @@ -29,12 +30,18 @@ export function CollectionSearch({ onKeyDown, className, isSearching, + disabled, }: CollectionSearchProps) { return (
-