Merge pull request #6188 from menloresearch/feat/mcp-enhancement
feat: mcp enhancement
This commit is contained in:
commit
362324cb87
@ -44,9 +44,10 @@ jan-utils = { path = "./utils" }
|
|||||||
libloading = "0.8.7"
|
libloading = "0.8.7"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
|
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
|
||||||
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "3196c95f1dfafbffbdcdd6d365c94969ac975e6a", features = [
|
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "209dbac50f51737ad953c3a2c8e28f3619b6c277", features = [
|
||||||
"client",
|
"client",
|
||||||
"transport-sse-client",
|
"transport-sse-client",
|
||||||
|
"transport-streamable-http-client",
|
||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
"tower",
|
"tower",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@ -327,4 +327,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -447,4 +447,4 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,15 +1,17 @@
|
|||||||
use rmcp::model::{CallToolRequestParam, CallToolResult, Tool};
|
use rmcp::model::{CallToolRequestParam, CallToolResult};
|
||||||
use rmcp::{service::RunningService, RoleClient};
|
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
|
||||||
use tauri::{AppHandle, Emitter, Runtime, State};
|
use tauri::{AppHandle, Emitter, Runtime, State};
|
||||||
use tokio::{sync::Mutex, time::timeout};
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT},
|
constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT},
|
||||||
helpers::{restart_active_mcp_servers, start_mcp_server_with_restart, stop_mcp_servers},
|
helpers::{restart_active_mcp_servers, start_mcp_server_with_restart, stop_mcp_servers},
|
||||||
};
|
};
|
||||||
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
|
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
|
||||||
|
use crate::core::{
|
||||||
|
mcp::models::ToolWithServer,
|
||||||
|
state::{RunningServiceEnum, SharedMcpServers},
|
||||||
|
};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -19,8 +21,7 @@ pub async fn activate_mcp_server<R: Runtime>(
|
|||||||
name: String,
|
name: String,
|
||||||
config: Value,
|
config: Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> =
|
let servers: SharedMcpServers = state.mcp_servers.clone();
|
||||||
state.mcp_servers.clone();
|
|
||||||
|
|
||||||
// Use the modified start_mcp_server_with_restart that returns first attempt result
|
// Use the modified start_mcp_server_with_restart that returns first attempt result
|
||||||
start_mcp_server_with_restart(app, servers, name, config, Some(3)).await
|
start_mcp_server_with_restart(app, servers, name, config, Some(3)).await
|
||||||
@ -63,7 +64,16 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) ->
|
|||||||
// Release the lock before calling cancel
|
// Release the lock before calling cancel
|
||||||
drop(servers_map);
|
drop(servers_map);
|
||||||
|
|
||||||
service.cancel().await.map_err(|e| e.to_string())?;
|
match service {
|
||||||
|
RunningServiceEnum::NoInit(service) => {
|
||||||
|
log::info!("Stopping server {name}...");
|
||||||
|
service.cancel().await.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
RunningServiceEnum::WithInit(service) => {
|
||||||
|
log::info!("Stopping server {name} with initialization...");
|
||||||
|
service.cancel().await.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
log::info!("Server {name} stopped successfully and marked as deactivated.");
|
log::info!("Server {name} stopped successfully and marked as deactivated.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -116,7 +126,7 @@ pub async fn get_connected_servers(
|
|||||||
Ok(servers_map.keys().cloned().collect())
|
Ok(servers_map.keys().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves all available tools from all MCP servers
|
/// Retrieves all available tools from all MCP servers with server information
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `state` - Application state containing MCP server connections
|
/// * `state` - Application state containing MCP server connections
|
||||||
@ -128,14 +138,15 @@ pub async fn get_connected_servers(
|
|||||||
/// 1. Locks the MCP servers mutex to access server connections
|
/// 1. Locks the MCP servers mutex to access server connections
|
||||||
/// 2. Iterates through all connected servers
|
/// 2. Iterates through all connected servers
|
||||||
/// 3. Gets the list of tools from each server
|
/// 3. Gets the list of tools from each server
|
||||||
/// 4. Combines all tools into a single vector
|
/// 4. Associates each tool with its parent server name
|
||||||
/// 5. Returns the combined list of all available tools
|
/// 5. Combines all tools into a single vector
|
||||||
|
/// 6. Returns the combined list of all available tools with server information
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String> {
|
pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<ToolWithServer>, String> {
|
||||||
let servers = state.mcp_servers.lock().await;
|
let servers = state.mcp_servers.lock().await;
|
||||||
let mut all_tools: Vec<Tool> = Vec::new();
|
let mut all_tools: Vec<ToolWithServer> = Vec::new();
|
||||||
|
|
||||||
for (_, service) in servers.iter() {
|
for (server_name, service) in servers.iter() {
|
||||||
// List tools with timeout
|
// List tools with timeout
|
||||||
let tools_future = service.list_all_tools();
|
let tools_future = service.list_all_tools();
|
||||||
let tools = match timeout(MCP_TOOL_CALL_TIMEOUT, tools_future).await {
|
let tools = match timeout(MCP_TOOL_CALL_TIMEOUT, tools_future).await {
|
||||||
@ -150,7 +161,12 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result<Vec<Tool>, String>
|
|||||||
};
|
};
|
||||||
|
|
||||||
for tool in tools {
|
for tool in tools {
|
||||||
all_tools.push(tool);
|
all_tools.push(ToolWithServer {
|
||||||
|
name: tool.name.to_string(),
|
||||||
|
description: tool.description.as_ref().map(|d| d.to_string()),
|
||||||
|
input_schema: serde_json::Value::Object((*tool.input_schema).clone()),
|
||||||
|
server: server_name.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
use rmcp::{service::RunningService, transport::TokioChildProcess, RoleClient, ServiceExt};
|
use rmcp::{
|
||||||
|
model::{ClientCapabilities, ClientInfo, Implementation},
|
||||||
|
transport::{
|
||||||
|
streamable_http_client::StreamableHttpClientTransportConfig, SseClientTransport,
|
||||||
|
StreamableHttpClientTransport, TokioChildProcess,
|
||||||
|
},
|
||||||
|
ServiceExt,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{collections::HashMap, env, sync::Arc, time::Duration};
|
use std::{collections::HashMap, env, sync::Arc, time::Duration};
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
|
||||||
|
use tauri_plugin_http::reqwest;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
process::Command,
|
process::Command,
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
@ -11,7 +19,11 @@ use tokio::{
|
|||||||
use super::constants::{
|
use super::constants::{
|
||||||
MCP_BACKOFF_MULTIPLIER, MCP_BASE_RESTART_DELAY_MS, MCP_MAX_RESTART_DELAY_MS,
|
MCP_BACKOFF_MULTIPLIER, MCP_BASE_RESTART_DELAY_MS, MCP_MAX_RESTART_DELAY_MS,
|
||||||
};
|
};
|
||||||
use crate::core::{app::commands::get_jan_data_folder_path, state::AppState};
|
use crate::core::{
|
||||||
|
app::commands::get_jan_data_folder_path,
|
||||||
|
mcp::models::McpServerConfig,
|
||||||
|
state::{AppState, RunningServiceEnum, SharedMcpServers},
|
||||||
|
};
|
||||||
use jan_utils::can_override_npx;
|
use jan_utils::can_override_npx;
|
||||||
|
|
||||||
/// Calculate exponential backoff delay with jitter
|
/// Calculate exponential backoff delay with jitter
|
||||||
@ -72,7 +84,7 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
|
|||||||
/// * `Err(String)` if there was an error reading config or starting servers
|
/// * `Err(String)` if there was an error reading config or starting servers
|
||||||
pub async fn run_mcp_commands<R: Runtime>(
|
pub async fn run_mcp_commands<R: Runtime>(
|
||||||
app: &AppHandle<R>,
|
app: &AppHandle<R>,
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let app_path = get_jan_data_folder_path(app.clone());
|
let app_path = get_jan_data_folder_path(app.clone());
|
||||||
let app_path_str = app_path.to_str().unwrap().to_string();
|
let app_path_str = app_path.to_str().unwrap().to_string();
|
||||||
@ -168,7 +180,7 @@ pub async fn run_mcp_commands<R: Runtime>(
|
|||||||
|
|
||||||
/// Monitor MCP server health without removing it from the HashMap
|
/// Monitor MCP server health without removing it from the HashMap
|
||||||
pub async fn monitor_mcp_server_handle(
|
pub async fn monitor_mcp_server_handle(
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Option<rmcp::service::QuitReason> {
|
) -> Option<rmcp::service::QuitReason> {
|
||||||
log::info!("Monitoring MCP server {} health", name);
|
log::info!("Monitoring MCP server {} health", name);
|
||||||
@ -213,7 +225,16 @@ pub async fn monitor_mcp_server_handle(
|
|||||||
let mut servers = servers_state.lock().await;
|
let mut servers = servers_state.lock().await;
|
||||||
if let Some(service) = servers.remove(&name) {
|
if let Some(service) = servers.remove(&name) {
|
||||||
// Try to cancel the service gracefully
|
// Try to cancel the service gracefully
|
||||||
let _ = service.cancel().await;
|
match service {
|
||||||
|
RunningServiceEnum::NoInit(service) => {
|
||||||
|
log::info!("Stopping server {name}...");
|
||||||
|
let _ = service.cancel().await;
|
||||||
|
}
|
||||||
|
RunningServiceEnum::WithInit(service) => {
|
||||||
|
log::info!("Stopping server {name} with initialization...");
|
||||||
|
let _ = service.cancel().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Some(rmcp::service::QuitReason::Closed);
|
return Some(rmcp::service::QuitReason::Closed);
|
||||||
}
|
}
|
||||||
@ -224,7 +245,7 @@ pub async fn monitor_mcp_server_handle(
|
|||||||
/// Returns the result of the first start attempt, then continues with restart monitoring
|
/// Returns the result of the first start attempt, then continues with restart monitoring
|
||||||
pub async fn start_mcp_server_with_restart<R: Runtime>(
|
pub async fn start_mcp_server_with_restart<R: Runtime>(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<R>,
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
name: String,
|
name: String,
|
||||||
config: Value,
|
config: Value,
|
||||||
max_restarts: Option<u32>,
|
max_restarts: Option<u32>,
|
||||||
@ -297,7 +318,7 @@ pub async fn start_mcp_server_with_restart<R: Runtime>(
|
|||||||
/// Helper function to handle the restart loop logic
|
/// Helper function to handle the restart loop logic
|
||||||
pub async fn start_restart_loop<R: Runtime>(
|
pub async fn start_restart_loop<R: Runtime>(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<R>,
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
name: String,
|
name: String,
|
||||||
config: Value,
|
config: Value,
|
||||||
max_restarts: u32,
|
max_restarts: u32,
|
||||||
@ -450,9 +471,9 @@ pub async fn start_restart_loop<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn schedule_mcp_start_task<R: Runtime>(
|
async fn schedule_mcp_start_task<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers: SharedMcpServers,
|
||||||
name: String,
|
name: String,
|
||||||
config: Value,
|
config: Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -463,136 +484,278 @@ pub async fn schedule_mcp_start_task<R: Runtime>(
|
|||||||
.expect("Executable must have a parent directory");
|
.expect("Executable must have a parent directory");
|
||||||
let bin_path = exe_parent_path.to_path_buf();
|
let bin_path = exe_parent_path.to_path_buf();
|
||||||
|
|
||||||
let (command, args, envs) = extract_command_args(&config)
|
let config_params = extract_command_args(&config)
|
||||||
.ok_or_else(|| format!("Failed to extract command args from config for {name}"))?;
|
.ok_or_else(|| format!("Failed to extract command args from config for {name}"))?;
|
||||||
|
|
||||||
let mut cmd = Command::new(command.clone());
|
if config_params.transport_type.as_deref() == Some("http") && config_params.url.is_some() {
|
||||||
|
let transport = StreamableHttpClientTransport::with_client(
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.default_headers({
|
||||||
|
// Map envs to request headers
|
||||||
|
let mut headers: tauri::http::HeaderMap = reqwest::header::HeaderMap::new();
|
||||||
|
for (key, value) in config_params.headers.iter() {
|
||||||
|
if let Some(v_str) = value.as_str() {
|
||||||
|
// Try to map env keys to HTTP header names (case-insensitive)
|
||||||
|
// Most HTTP headers are Title-Case, so we try to convert
|
||||||
|
let header_name =
|
||||||
|
reqwest::header::HeaderName::from_bytes(key.as_bytes());
|
||||||
|
if let Ok(header_name) = header_name {
|
||||||
|
if let Ok(header_value) =
|
||||||
|
reqwest::header::HeaderValue::from_str(v_str)
|
||||||
|
{
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
.connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
StreamableHttpClientTransportConfig {
|
||||||
|
uri: config_params.url.unwrap().into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if command == "npx" && can_override_npx() {
|
let client_info = ClientInfo {
|
||||||
let mut cache_dir = app_path.clone();
|
protocol_version: Default::default(),
|
||||||
cache_dir.push(".npx");
|
capabilities: ClientCapabilities::default(),
|
||||||
let bun_x_path = format!("{}/bun", bin_path.display());
|
client_info: Implementation {
|
||||||
cmd = Command::new(bun_x_path);
|
name: "Jan Streamable Client".to_string(),
|
||||||
cmd.arg("x");
|
version: "0.0.1".to_string(),
|
||||||
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
|
},
|
||||||
}
|
};
|
||||||
|
let client = client_info.serve(transport).await.inspect_err(|e| {
|
||||||
|
log::error!("client error: {:?}", e);
|
||||||
|
});
|
||||||
|
|
||||||
if command == "uvx" {
|
match client {
|
||||||
let mut cache_dir = app_path.clone();
|
Ok(client) => {
|
||||||
cache_dir.push(".uvx");
|
log::info!("Connected to server: {:?}", client.peer_info());
|
||||||
let bun_x_path = format!("{}/uv", bin_path.display());
|
servers
|
||||||
cmd = Command::new(bun_x_path);
|
.lock()
|
||||||
cmd.arg("tool");
|
.await
|
||||||
cmd.arg("run");
|
.insert(name.clone(), RunningServiceEnum::WithInit(client));
|
||||||
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
// Mark server as successfully connected (for restart policy)
|
||||||
|
{
|
||||||
|
let app_state = app.state::<AppState>();
|
||||||
|
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||||
|
connected.insert(name.clone(), true);
|
||||||
|
log::info!("Marked MCP server {} as successfully connected", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to connect to server: {}", e);
|
||||||
|
return Err(format!("Failed to connect to server: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some()
|
||||||
{
|
{
|
||||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
|
let transport = SseClientTransport::start_with_client(
|
||||||
}
|
reqwest::Client::builder()
|
||||||
|
.default_headers({
|
||||||
let app_path_str = app_path.to_str().unwrap().to_string();
|
// Map envs to request headers
|
||||||
let log_file_path = format!("{}/logs/app.log", app_path_str);
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
match std::fs::OpenOptions::new()
|
for (key, value) in config_params.headers.iter() {
|
||||||
.create(true)
|
if let Some(v_str) = value.as_str() {
|
||||||
.append(true)
|
// Try to map env keys to HTTP header names (case-insensitive)
|
||||||
.open(log_file_path)
|
// Most HTTP headers are Title-Case, so we try to convert
|
||||||
{
|
let header_name =
|
||||||
Ok(file) => {
|
reqwest::header::HeaderName::from_bytes(key.as_bytes());
|
||||||
cmd.stderr(std::process::Stdio::from(file));
|
if let Ok(header_name) = header_name {
|
||||||
}
|
if let Ok(header_value) =
|
||||||
Err(err) => {
|
reqwest::header::HeaderValue::from_str(v_str)
|
||||||
log::error!("Failed to open log file: {}", err);
|
{
|
||||||
}
|
headers.insert(header_name, header_value);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
cmd.kill_on_drop(true);
|
}
|
||||||
log::trace!("Command: {cmd:#?}");
|
}
|
||||||
|
headers
|
||||||
args.iter().filter_map(Value::as_str).for_each(|arg| {
|
})
|
||||||
cmd.arg(arg);
|
.connect_timeout(config_params.timeout.unwrap_or(Duration::MAX))
|
||||||
});
|
.build()
|
||||||
envs.iter().for_each(|(k, v)| {
|
.unwrap(),
|
||||||
if let Some(v_str) = v.as_str() {
|
rmcp::transport::sse_client::SseClientConfig {
|
||||||
cmd.env(k, v_str);
|
sse_endpoint: config_params.url.unwrap().into(),
|
||||||
}
|
..Default::default()
|
||||||
});
|
},
|
||||||
|
)
|
||||||
let process = TokioChildProcess::new(cmd).map_err(|e| {
|
|
||||||
log::error!("Failed to run command {name}: {e}");
|
|
||||||
format!("Failed to run command {name}: {e}")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let service = ()
|
|
||||||
.serve(process)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?;
|
.map_err(|e| {
|
||||||
|
log::error!("transport error: {:?}", e);
|
||||||
|
format!("Failed to start SSE transport: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Get peer info and clone the needed values before moving the service
|
let client_info = ClientInfo {
|
||||||
let (server_name, server_version) = {
|
protocol_version: Default::default(),
|
||||||
|
capabilities: ClientCapabilities::default(),
|
||||||
|
client_info: Implementation {
|
||||||
|
name: "Jan SSE Client".to_string(),
|
||||||
|
version: "0.0.1".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let client = client_info.serve(transport).await.map_err(|e| {
|
||||||
|
log::error!("client error: {:?}", e);
|
||||||
|
e.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
match client {
|
||||||
|
Ok(client) => {
|
||||||
|
log::info!("Connected to server: {:?}", client.peer_info());
|
||||||
|
servers
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(name.clone(), RunningServiceEnum::WithInit(client));
|
||||||
|
|
||||||
|
// Mark server as successfully connected (for restart policy)
|
||||||
|
{
|
||||||
|
let app_state = app.state::<AppState>();
|
||||||
|
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||||
|
connected.insert(name.clone(), true);
|
||||||
|
log::info!("Marked MCP server {} as successfully connected", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to connect to server: {}", e);
|
||||||
|
return Err(format!("Failed to connect to server: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut cmd = Command::new(config_params.command.clone());
|
||||||
|
if config_params.command.clone() == "npx" && can_override_npx() {
|
||||||
|
let mut cache_dir = app_path.clone();
|
||||||
|
cache_dir.push(".npx");
|
||||||
|
let bun_x_path = format!("{}/bun", bin_path.display());
|
||||||
|
cmd = Command::new(bun_x_path);
|
||||||
|
cmd.arg("x");
|
||||||
|
cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string());
|
||||||
|
}
|
||||||
|
if config_params.command.clone() == "uvx" {
|
||||||
|
let mut cache_dir = app_path.clone();
|
||||||
|
cache_dir.push(".uvx");
|
||||||
|
let bun_x_path = format!("{}/uv", bin_path.display());
|
||||||
|
cmd = Command::new(bun_x_path);
|
||||||
|
cmd.arg("tool");
|
||||||
|
cmd.arg("run");
|
||||||
|
cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string());
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
|
||||||
|
}
|
||||||
|
let app_path_str = app_path.to_str().unwrap().to_string();
|
||||||
|
let log_file_path = format!("{}/logs/app.log", app_path_str);
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(log_file_path)
|
||||||
|
{
|
||||||
|
Ok(file) => {
|
||||||
|
cmd.stderr(std::process::Stdio::from(file));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to open log file: {}", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
log::trace!("Command: {cmd:#?}");
|
||||||
|
|
||||||
|
config_params
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.for_each(|arg| {
|
||||||
|
cmd.arg(arg);
|
||||||
|
});
|
||||||
|
config_params.envs.iter().for_each(|(k, v)| {
|
||||||
|
if let Some(v_str) = v.as_str() {
|
||||||
|
cmd.env(k, v_str);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let process = TokioChildProcess::new(cmd).map_err(|e| {
|
||||||
|
log::error!("Failed to run command {name}: {e}");
|
||||||
|
format!("Failed to run command {name}: {e}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let service = ()
|
||||||
|
.serve(process)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?;
|
||||||
|
|
||||||
|
// Get peer info and clone the needed values before moving the service
|
||||||
let server_info = service.peer_info();
|
let server_info = service.peer_info();
|
||||||
log::trace!("Connected to server: {server_info:#?}");
|
log::trace!("Connected to server: {server_info:#?}");
|
||||||
(
|
|
||||||
server_info.unwrap().server_info.name.clone(),
|
|
||||||
server_info.unwrap().server_info.version.clone(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Now move the service into the HashMap
|
// Now move the service into the HashMap
|
||||||
servers.lock().await.insert(name.clone(), service);
|
servers
|
||||||
log::info!("Server {name} started successfully.");
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(name.clone(), RunningServiceEnum::NoInit(service));
|
||||||
|
log::info!("Server {name} started successfully.");
|
||||||
|
|
||||||
// Wait a short time to verify the server is stable before marking as connected
|
// Wait a short time to verify the server is stable before marking as connected
|
||||||
// This prevents race conditions where the server quits immediately
|
// This prevents race conditions where the server quits immediately
|
||||||
let verification_delay = Duration::from_millis(500);
|
let verification_delay = Duration::from_millis(500);
|
||||||
sleep(verification_delay).await;
|
sleep(verification_delay).await;
|
||||||
|
|
||||||
// Check if server is still running after the verification delay
|
// Check if server is still running after the verification delay
|
||||||
let server_still_running = {
|
let server_still_running = {
|
||||||
let servers_map = servers.lock().await;
|
let servers_map = servers.lock().await;
|
||||||
servers_map.contains_key(&name)
|
servers_map.contains_key(&name)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !server_still_running {
|
if !server_still_running {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"MCP server {} quit immediately after starting",
|
"MCP server {} quit immediately after starting",
|
||||||
name
|
name
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
// Mark server as successfully connected (for restart policy)
|
||||||
|
{
|
||||||
|
let app_state = app.state::<AppState>();
|
||||||
|
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
||||||
|
connected.insert(name.clone(), true);
|
||||||
|
log::info!("Marked MCP server {} as successfully connected", name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark server as successfully connected (for restart policy)
|
|
||||||
{
|
|
||||||
let app_state = app.state::<AppState>();
|
|
||||||
let mut connected = app_state.mcp_successfully_connected.lock().await;
|
|
||||||
connected.insert(name.clone(), true);
|
|
||||||
log::info!("Marked MCP server {} as successfully connected", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit event to the frontend
|
|
||||||
let event = format!("mcp-connected");
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"name": server_name,
|
|
||||||
"version": server_version,
|
|
||||||
});
|
|
||||||
app.emit(&event, payload)
|
|
||||||
.map_err(|e| format!("Failed to emit event: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_command_args(
|
pub fn extract_command_args(config: &Value) -> Option<McpServerConfig> {
|
||||||
config: &Value,
|
|
||||||
) -> Option<(String, Vec<Value>, serde_json::Map<String, Value>)> {
|
|
||||||
let obj = config.as_object()?;
|
let obj = config.as_object()?;
|
||||||
let command = obj.get("command")?.as_str()?.to_string();
|
let command = obj.get("command")?.as_str()?.to_string();
|
||||||
let args = obj.get("args")?.as_array()?.clone();
|
let args = obj.get("args")?.as_array()?.clone();
|
||||||
|
let url = obj.get("url").and_then(|u| u.as_str()).map(String::from);
|
||||||
|
let transport_type = obj.get("type").and_then(|t| t.as_str()).map(String::from);
|
||||||
|
let timeout = obj
|
||||||
|
.get("timeout")
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
.map(Duration::from_secs);
|
||||||
|
let headers = obj
|
||||||
|
.get("headers")
|
||||||
|
.unwrap_or(&Value::Object(serde_json::Map::new()))
|
||||||
|
.as_object()?
|
||||||
|
.clone();
|
||||||
let envs = obj
|
let envs = obj
|
||||||
.get("env")
|
.get("env")
|
||||||
.unwrap_or(&Value::Object(serde_json::Map::new()))
|
.unwrap_or(&Value::Object(serde_json::Map::new()))
|
||||||
.as_object()?
|
.as_object()?
|
||||||
.clone();
|
.clone();
|
||||||
Some((command, args, envs))
|
Some(McpServerConfig {
|
||||||
|
timeout,
|
||||||
|
transport_type,
|
||||||
|
url,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
envs,
|
||||||
|
headers
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_active_status(config: &Value) -> Option<bool> {
|
pub fn extract_active_status(config: &Value) -> Option<bool> {
|
||||||
@ -604,7 +767,7 @@ pub fn extract_active_status(config: &Value) -> Option<bool> {
|
|||||||
/// Restart only servers that were previously active (like cortex restart behavior)
|
/// Restart only servers that were previously active (like cortex restart behavior)
|
||||||
pub async fn restart_active_mcp_servers<R: Runtime>(
|
pub async fn restart_active_mcp_servers<R: Runtime>(
|
||||||
app: &AppHandle<R>,
|
app: &AppHandle<R>,
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let app_state = app.state::<AppState>();
|
let app_state = app.state::<AppState>();
|
||||||
let active_servers = app_state.mcp_active_servers.lock().await;
|
let active_servers = app_state.mcp_active_servers.lock().await;
|
||||||
@ -656,14 +819,21 @@ pub async fn clean_up_mcp_servers(state: State<'_, AppState>) {
|
|||||||
log::info!("MCP servers cleaned up successfully");
|
log::info!("MCP servers cleaned up successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop_mcp_servers(
|
pub async fn stop_mcp_servers(servers_state: SharedMcpServers) -> Result<(), String> {
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut servers_map = servers_state.lock().await;
|
let mut servers_map = servers_state.lock().await;
|
||||||
let keys: Vec<String> = servers_map.keys().cloned().collect();
|
let keys: Vec<String> = servers_map.keys().cloned().collect();
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let Some(service) = servers_map.remove(&key) {
|
if let Some(service) = servers_map.remove(&key) {
|
||||||
service.cancel().await.map_err(|e| e.to_string())?;
|
match service {
|
||||||
|
RunningServiceEnum::NoInit(service) => {
|
||||||
|
log::info!("Stopping server {key}...");
|
||||||
|
service.cancel().await.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
RunningServiceEnum::WithInit(service) => {
|
||||||
|
log::info!("Stopping server {key} with initialization...");
|
||||||
|
service.cancel().await.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(servers_map); // Release the lock after stopping
|
drop(servers_map); // Release the lock after stopping
|
||||||
@ -689,7 +859,7 @@ pub async fn reset_restart_count(restart_counts: &Arc<Mutex<HashMap<String, u32>
|
|||||||
/// Spawn the server monitoring task for handling restarts
|
/// Spawn the server monitoring task for handling restarts
|
||||||
pub async fn spawn_server_monitoring_task<R: Runtime>(
|
pub async fn spawn_server_monitoring_task<R: Runtime>(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<R>,
|
||||||
servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
servers_state: SharedMcpServers,
|
||||||
name: String,
|
name: String,
|
||||||
config: Value,
|
config: Value,
|
||||||
max_restarts: u32,
|
max_restarts: u32,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
mod constants;
|
mod constants;
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
26
src-tauri/src/core/mcp/models.rs
Normal file
26
src-tauri/src/core/mcp/models.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Configuration parameters extracted from MCP server config
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct McpServerConfig {
|
||||||
|
pub transport_type: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub command: String,
|
||||||
|
pub args: Vec<Value>,
|
||||||
|
pub envs: serde_json::Map<String, Value>,
|
||||||
|
pub timeout: Option<Duration>,
|
||||||
|
pub headers: serde_json::Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool with server information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolWithServer {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(rename = "inputSchema")]
|
||||||
|
pub input_schema: serde_json::Value,
|
||||||
|
pub server: String,
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
use super::helpers::run_mcp_commands;
|
use super::helpers::run_mcp_commands;
|
||||||
use crate::core::app::commands::get_jan_data_folder_path;
|
use crate::core::app::commands::get_jan_data_folder_path;
|
||||||
use rmcp::{service::RunningService, RoleClient};
|
use crate::core::state::SharedMcpServers;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@ -27,7 +27,7 @@ async fn test_run_mcp_commands() {
|
|||||||
.expect("Failed to write to config file");
|
.expect("Failed to write to config file");
|
||||||
|
|
||||||
// Call the run_mcp_commands function
|
// Call the run_mcp_commands function
|
||||||
let servers_state: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>> =
|
let servers_state: SharedMcpServers =
|
||||||
Arc::new(Mutex::new(HashMap::new()));
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
let result = run_mcp_commands(app.handle(), servers_state).await;
|
let result = run_mcp_commands(app.handle(), servers_state).await;
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,48 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use crate::core::downloads::models::DownloadManagerState;
|
use crate::core::downloads::models::DownloadManagerState;
|
||||||
use rmcp::{service::RunningService, RoleClient};
|
use rmcp::{
|
||||||
|
model::{CallToolRequestParam, CallToolResult, InitializeRequestParam, Tool},
|
||||||
|
service::RunningService,
|
||||||
|
RoleClient, ServiceError,
|
||||||
|
};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
/// Server handle type for managing the proxy server lifecycle
|
/// Server handle type for managing the proxy server lifecycle
|
||||||
pub type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>;
|
pub type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
pub enum RunningServiceEnum {
|
||||||
|
NoInit(RunningService<RoleClient, ()>),
|
||||||
|
WithInit(RunningService<RoleClient, InitializeRequestParam>),
|
||||||
|
}
|
||||||
|
pub type SharedMcpServers = Arc<Mutex<HashMap<String, RunningServiceEnum>>>;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub app_token: Option<String>,
|
pub app_token: Option<String>,
|
||||||
pub mcp_servers: Arc<Mutex<HashMap<String, RunningService<RoleClient, ()>>>>,
|
pub mcp_servers: SharedMcpServers,
|
||||||
pub download_manager: Arc<Mutex<DownloadManagerState>>,
|
pub download_manager: Arc<Mutex<DownloadManagerState>>,
|
||||||
pub mcp_restart_counts: Arc<Mutex<HashMap<String, u32>>>,
|
pub mcp_restart_counts: Arc<Mutex<HashMap<String, u32>>>,
|
||||||
pub mcp_active_servers: Arc<Mutex<HashMap<String, serde_json::Value>>>,
|
pub mcp_active_servers: Arc<Mutex<HashMap<String, serde_json::Value>>>,
|
||||||
pub mcp_successfully_connected: Arc<Mutex<HashMap<String, bool>>>,
|
pub mcp_successfully_connected: Arc<Mutex<HashMap<String, bool>>>,
|
||||||
pub server_handle: Arc<Mutex<Option<ServerHandle>>>,
|
pub server_handle: Arc<Mutex<Option<ServerHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RunningServiceEnum {
|
||||||
|
pub async fn list_all_tools(&self) -> Result<Vec<Tool>, ServiceError> {
|
||||||
|
match self {
|
||||||
|
Self::NoInit(s) => s.list_all_tools().await,
|
||||||
|
Self::WithInit(s) => s.list_all_tools().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn call_tool(
|
||||||
|
&self,
|
||||||
|
params: CallToolRequestParam,
|
||||||
|
) -> Result<CallToolResult, ServiceError> {
|
||||||
|
match self {
|
||||||
|
Self::NoInit(s) => s.call_tool(params).await,
|
||||||
|
Self::WithInit(s) => s.call_tool(params).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,11 +17,12 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@janhq/core": "link:../core",
|
"@janhq/core": "link:../core",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.10",
|
||||||
"@radix-ui/react-dialog": "^1.1.11",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.11",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-hover-card": "^1.1.14",
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
"@radix-ui/react-popover": "^1.1.13",
|
"@radix-ui/react-popover": "^1.1.13",
|
||||||
"@radix-ui/react-progress": "^1.1.4",
|
"@radix-ui/react-progress": "^1.1.4",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@radix-ui/react-switch": "^1.2.2",
|
"@radix-ui/react-switch": "^1.2.2",
|
||||||
@ -43,13 +44,14 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"culori": "^4.0.1",
|
"culori": "^4.0.1",
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"fzf": "^0.5.2",
|
"fzf": "^0.5.2",
|
||||||
"i18next": "^25.0.1",
|
"i18next": "^25.0.1",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.522.0",
|
"lucide-react": "^0.536.0",
|
||||||
"motion": "^12.10.5",
|
"motion": "^12.10.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"posthog-js": "^1.246.0",
|
"posthog-js": "^1.246.0",
|
||||||
@ -75,6 +77,7 @@
|
|||||||
"ulidx": "^2.4.1",
|
"ulidx": "^2.4.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -104,7 +107,7 @@
|
|||||||
"istanbul-lib-report": "^3.0.1",
|
"istanbul-lib-report": "^3.0.1",
|
||||||
"istanbul-reports": "^3.1.7",
|
"istanbul-reports": "^3.1.7",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.3.1",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.26.1",
|
"typescript-eslint": "^8.26.1",
|
||||||
"vite": "^6.3.0",
|
"vite": "^6.3.0",
|
||||||
|
|||||||
533
web-app/src/components/ui/__tests__/dropdrawer.test.tsx
Normal file
533
web-app/src/components/ui/__tests__/dropdrawer.test.tsx
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropDrawer,
|
||||||
|
DropDrawerContent,
|
||||||
|
DropDrawerFooter,
|
||||||
|
DropDrawerGroup,
|
||||||
|
DropDrawerItem,
|
||||||
|
DropDrawerLabel,
|
||||||
|
DropDrawerSeparator,
|
||||||
|
DropDrawerSub,
|
||||||
|
DropDrawerSubContent,
|
||||||
|
DropDrawerSubTrigger,
|
||||||
|
DropDrawerTrigger,
|
||||||
|
} from '../dropdrawer'
|
||||||
|
|
||||||
|
// Mock the media query hook
|
||||||
|
const mockUseSmallScreen = vi.fn()
|
||||||
|
vi.mock('@/hooks/useMediaQuery', () => ({
|
||||||
|
useSmallScreen: () => mockUseSmallScreen(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock framer-motion to avoid animation complexity in tests
|
||||||
|
vi.mock('framer-motion', () => ({
|
||||||
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div data-testid="animate-presence">{children}</div>,
|
||||||
|
motion: {
|
||||||
|
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('DropDrawer Utilities', () => {
|
||||||
|
it('renders without crashing', () => {
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Test</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DropDrawer Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Desktop Mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dropdown menu on desktop', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
<DropDrawerItem>Item 2</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dropdown menu structure', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Desktop Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only the trigger is visible initially
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'menu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures dropdown with separators', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
<DropDrawerSeparator />
|
||||||
|
<DropDrawerItem>Item 2</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify component structure - content is not visible until opened
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures dropdown with labels', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerLabel>Menu Section</DropDrawerLabel>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only verify trigger is present - content shows on interaction
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile Mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders drawer on mobile', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Mobile Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders drawer structure', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Mobile Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify drawer trigger is present
|
||||||
|
const trigger = screen.getByText('Open Drawer')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'dialog')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render separators in mobile mode', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
<DropDrawerSeparator />
|
||||||
|
<DropDrawerItem>Item 2</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mobile separators return null, so they shouldn't be in the DOM
|
||||||
|
const separators = screen.queryAllByRole('separator')
|
||||||
|
expect(separators).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders drawer with labels structure', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerLabel>Drawer Section</DropDrawerLabel>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify drawer structure is present
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DropDrawerItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be structured with click handlers', () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem onClick={handleClick}>Clickable Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify structure is valid
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
expect(handleClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be structured with icons', () => {
|
||||||
|
const TestIcon = () => <span data-testid="test-icon">Icon</span>
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem icon={<TestIcon />}>Item with Icon</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Structure is valid
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts variant props', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem variant="destructive">
|
||||||
|
Delete Item
|
||||||
|
</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid with variants
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts disabled prop', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem disabled>
|
||||||
|
Disabled Item
|
||||||
|
</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid with disabled prop
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DropDrawerGroup', () => {
|
||||||
|
it('structures groups in desktop mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerGroup>
|
||||||
|
<DropDrawerItem>Group Item 1</DropDrawerItem>
|
||||||
|
<DropDrawerItem>Group Item 2</DropDrawerItem>
|
||||||
|
</DropDrawerGroup>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures groups in mobile mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerGroup>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
<DropDrawerItem>Item 2</DropDrawerItem>
|
||||||
|
</DropDrawerGroup>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid in mobile mode
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DropDrawerFooter', () => {
|
||||||
|
it('structures footer in desktop mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item</DropDrawerItem>
|
||||||
|
<DropDrawerFooter>Footer Content</DropDrawerFooter>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures footer in mobile mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Item</DropDrawerItem>
|
||||||
|
<DropDrawerFooter>Mobile Footer</DropDrawerFooter>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid in mobile mode
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Submenu Components', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures submenu in desktop mode', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerSub>
|
||||||
|
<DropDrawerSubTrigger>Submenu Trigger</DropDrawerSubTrigger>
|
||||||
|
<DropDrawerSubContent>
|
||||||
|
<DropDrawerItem>Submenu Item</DropDrawerItem>
|
||||||
|
</DropDrawerSubContent>
|
||||||
|
</DropDrawerSub>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('structures submenu in mobile mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerSub>
|
||||||
|
<DropDrawerSubTrigger>
|
||||||
|
Mobile Submenu
|
||||||
|
</DropDrawerSubTrigger>
|
||||||
|
<DropDrawerSubContent>
|
||||||
|
<DropDrawerItem>Submenu Item</DropDrawerItem>
|
||||||
|
</DropDrawerSubContent>
|
||||||
|
</DropDrawerSub>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure is valid in mobile mode
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles submenu content correctly in mobile mode', () => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerSub>
|
||||||
|
<DropDrawerSubTrigger>Mobile Submenu</DropDrawerSubTrigger>
|
||||||
|
<DropDrawerSubContent>
|
||||||
|
<DropDrawerItem>Hidden Item</DropDrawerItem>
|
||||||
|
</DropDrawerSubContent>
|
||||||
|
</DropDrawerSub>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component handles mobile submenu structure correctly
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maintains proper ARIA attributes on triggers', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerGroup>
|
||||||
|
<DropDrawerItem>Item 1</DropDrawerItem>
|
||||||
|
</DropDrawerGroup>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole('button')
|
||||||
|
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports disabled state', () => {
|
||||||
|
const handleClick = vi.fn()
|
||||||
|
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Drawer</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem disabled onClick={handleClick}>
|
||||||
|
Disabled Item
|
||||||
|
</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure supports disabled prop
|
||||||
|
expect(screen.getByText('Open Drawer')).toBeInTheDocument()
|
||||||
|
expect(handleClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Boundaries', () => {
|
||||||
|
it('requires proper context usage', () => {
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<DropDrawerItem>Orphan Item</DropDrawerItem>)
|
||||||
|
}).toThrow()
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Custom Props and Styling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger className="custom-trigger">Custom Trigger</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent className="custom-content">
|
||||||
|
<DropDrawerItem className="custom-item">Custom Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = screen.getByText('Custom Trigger')
|
||||||
|
expect(trigger).toHaveClass('custom-trigger')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts additional props', () => {
|
||||||
|
render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Open Menu</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem data-custom="test-value">Custom Props Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component structure accepts custom props
|
||||||
|
expect(screen.getByText('Open Menu')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Responsive Behavior', () => {
|
||||||
|
it('adapts to different screen sizes', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Responsive Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Desktop mode
|
||||||
|
mockUseSmallScreen.mockReturnValue(false)
|
||||||
|
rerender(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Responsive Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
let trigger = screen.getByText('Responsive Trigger')
|
||||||
|
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
|
||||||
|
|
||||||
|
// Mobile mode
|
||||||
|
mockUseSmallScreen.mockReturnValue(true)
|
||||||
|
rerender(
|
||||||
|
<DropDrawer>
|
||||||
|
<DropDrawerTrigger>Responsive Trigger</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent>
|
||||||
|
<DropDrawerItem>Responsive Item</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
|
)
|
||||||
|
|
||||||
|
trigger = screen.getByText('Responsive Trigger')
|
||||||
|
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
62
web-app/src/components/ui/__tests__/radio-group.test.tsx
Normal file
62
web-app/src/components/ui/__tests__/radio-group.test.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '../radio-group'
|
||||||
|
|
||||||
|
describe('RadioGroup', () => {
|
||||||
|
it('renders radio items correctly', () => {
|
||||||
|
render(
|
||||||
|
<RadioGroup defaultValue="http">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="http" id="http" />
|
||||||
|
<label htmlFor="http">HTTP</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="sse" id="sse" />
|
||||||
|
<label htmlFor="sse">SSE</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('HTTP')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('SSE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows selecting different options', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onValueChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RadioGroup defaultValue="http" onValueChange={onValueChange}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="http" id="http" />
|
||||||
|
<label htmlFor="http">HTTP</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="sse" id="sse" />
|
||||||
|
<label htmlFor="sse">SSE</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText('SSE'))
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('sse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has correct default selection', () => {
|
||||||
|
render(
|
||||||
|
<RadioGroup defaultValue="http">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="http" id="http" />
|
||||||
|
<label htmlFor="http">HTTP</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="sse" id="sse" />
|
||||||
|
<label htmlFor="sse">SSE</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('HTTP')).toBeChecked()
|
||||||
|
expect(screen.getByLabelText('SSE')).not.toBeChecked()
|
||||||
|
})
|
||||||
|
})
|
||||||
133
web-app/src/components/ui/drawer.tsx
Normal file
133
web-app/src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in backdrop-blur data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
'group/drawer-content fixed z-50 flex h-auto flex-col',
|
||||||
|
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||||
|
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||||
|
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||||
|
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-main-view-fg/10 mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn('font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn('text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
@ -61,14 +61,17 @@ function DropdownMenuGroup({
|
|||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative cursor-pointer hover:bg-main-view-fg/4 flex items-center gap-2 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative cursor-pointer hover:bg-main-view-fg/4 flex items-center gap-2 rounded-sm px-2 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
@ -213,7 +216,7 @@ function DropdownMenuSubTrigger({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4 text-main-view-fg/50" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
949
web-app/src/components/ui/dropdrawer.tsx
Normal file
949
web-app/src/components/ui/dropdrawer.tsx
Normal file
@ -0,0 +1,949 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from '@/components/ui/drawer'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
|
|
||||||
|
const ANIMATION_CONFIG = {
|
||||||
|
variants: {
|
||||||
|
enter: (direction: 'forward' | 'backward') => ({
|
||||||
|
x: direction === 'forward' ? '100%' : '-100%',
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
center: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: (direction: 'forward' | 'backward') => ({
|
||||||
|
x: direction === 'forward' ? '-100%' : '100%',
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1.0],
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const getMobileItemStyles = (
|
||||||
|
isInsideGroup: boolean,
|
||||||
|
inset?: boolean,
|
||||||
|
variant?: string,
|
||||||
|
disabled?: boolean
|
||||||
|
) => {
|
||||||
|
return cn(
|
||||||
|
'flex cursor-pointer items-center justify-between px-4 py-4 w-full gap-4',
|
||||||
|
!isInsideGroup && 'bg-main-view-fg/50 mx-2 my-1.5 rounded-md',
|
||||||
|
isInsideGroup && 'bg-transparent py-4',
|
||||||
|
inset && 'pl-8',
|
||||||
|
variant === 'destructive' && 'text-destructive',
|
||||||
|
disabled && 'pointer-events-none opacity-50'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropDrawerContext = React.createContext<{ isMobile: boolean }>({
|
||||||
|
isMobile: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const useDropDrawerContext = () => {
|
||||||
|
const context = React.useContext(DropDrawerContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'DropDrawer components cannot be rendered outside the DropDrawer Context'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const useComponentSelection = () => {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
|
||||||
|
const selectComponent = <T, D>(mobileComponent: T, desktopComponent: D) => {
|
||||||
|
return isMobile ? mobileComponent : desktopComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isMobile, selectComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useGroupDetection = () => {
|
||||||
|
const isInGroup = React.useCallback(
|
||||||
|
(element: HTMLElement | null): boolean => {
|
||||||
|
if (!element) return false
|
||||||
|
|
||||||
|
let parent = element.parentElement
|
||||||
|
while (parent) {
|
||||||
|
if (parent.hasAttribute('data-drop-drawer-group')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parent = parent.parentElement
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const useGroupState = () => {
|
||||||
|
const { isMobile } = useComponentSelection()
|
||||||
|
const itemRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [isInsideGroup, setIsInsideGroup] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isMobile) return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (itemRef.current) {
|
||||||
|
setIsInsideGroup(isInGroup(itemRef.current))
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
return { itemRef, isInsideGroup }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isInGroup, useGroupState }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConditionalComponentProps<T, D> = {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
} & (T | D)
|
||||||
|
|
||||||
|
const ConditionalComponent = <T, D>({
|
||||||
|
mobileComponent,
|
||||||
|
desktopComponent,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
mobileComponent: React.ComponentType<any>
|
||||||
|
desktopComponent: React.ComponentType<any>
|
||||||
|
children: React.ReactNode
|
||||||
|
} & ConditionalComponentProps<T, D>) => {
|
||||||
|
const { selectComponent } = useComponentSelection()
|
||||||
|
const Component = selectComponent(mobileComponent, desktopComponent)
|
||||||
|
|
||||||
|
return <Component {...props}>{children}</Component>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawer({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}:
|
||||||
|
| React.ComponentProps<typeof Drawer>
|
||||||
|
| React.ComponentProps<typeof DropdownMenu>) {
|
||||||
|
const isMobile = useSmallScreen()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropDrawerContext.Provider value={{ isMobile }}>
|
||||||
|
<ConditionalComponent
|
||||||
|
mobileComponent={Drawer}
|
||||||
|
desktopComponent={DropdownMenu}
|
||||||
|
data-slot="drop-drawer"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConditionalComponent>
|
||||||
|
</DropDrawerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}:
|
||||||
|
| React.ComponentProps<typeof DrawerTrigger>
|
||||||
|
| React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||||
|
return (
|
||||||
|
<ConditionalComponent
|
||||||
|
mobileComponent={DrawerTrigger}
|
||||||
|
desktopComponent={DropdownMenuTrigger}
|
||||||
|
data-slot="drop-drawer-trigger"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConditionalComponent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}:
|
||||||
|
| React.ComponentProps<typeof DrawerContent>
|
||||||
|
| React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
const [activeSubmenu, setActiveSubmenu] = React.useState<string | null>(null)
|
||||||
|
const [submenuTitle, setSubmenuTitle] = React.useState<string | null>(null)
|
||||||
|
const [submenuStack, setSubmenuStack] = React.useState<
|
||||||
|
{ id: string; title: string }[]
|
||||||
|
>([])
|
||||||
|
// Add animation direction state
|
||||||
|
const [animationDirection, setAnimationDirection] = React.useState<
|
||||||
|
'forward' | 'backward'
|
||||||
|
>('forward')
|
||||||
|
|
||||||
|
// Create a ref to store submenu content by ID
|
||||||
|
const submenuContentRef = React.useRef<Map<string, React.ReactNode[]>>(
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Function to navigate to a submenu
|
||||||
|
const navigateToSubmenu = React.useCallback((id: string, title: string) => {
|
||||||
|
// Set animation direction to forward when navigating to a submenu
|
||||||
|
setAnimationDirection('forward')
|
||||||
|
setActiveSubmenu(id)
|
||||||
|
setSubmenuTitle(title)
|
||||||
|
setSubmenuStack((prev) => [...prev, { id, title }])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Function to go back to previous menu
|
||||||
|
const goBack = React.useCallback(() => {
|
||||||
|
// Set animation direction to backward when going back
|
||||||
|
setAnimationDirection('backward')
|
||||||
|
|
||||||
|
if (submenuStack.length <= 1) {
|
||||||
|
// If we're at the first level, go back to main menu
|
||||||
|
setActiveSubmenu(null)
|
||||||
|
setSubmenuTitle(null)
|
||||||
|
setSubmenuStack([])
|
||||||
|
} else {
|
||||||
|
// Go back to previous submenu
|
||||||
|
const newStack = [...submenuStack]
|
||||||
|
newStack.pop() // Remove current
|
||||||
|
const previous = newStack[newStack.length - 1]
|
||||||
|
setActiveSubmenu(previous.id)
|
||||||
|
setSubmenuTitle(previous.title)
|
||||||
|
setSubmenuStack(newStack)
|
||||||
|
}
|
||||||
|
}, [submenuStack])
|
||||||
|
|
||||||
|
// Function to register submenu content
|
||||||
|
const registerSubmenuContent = React.useCallback(
|
||||||
|
(id: string, content: React.ReactNode[]) => {
|
||||||
|
submenuContentRef.current.set(id, content)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const extractSubmenuContent = React.useCallback(
|
||||||
|
(elements: React.ReactNode, targetId: string): React.ReactNode[] => {
|
||||||
|
const result: React.ReactNode[] = []
|
||||||
|
|
||||||
|
const findSubmenuContent = (node: React.ReactNode) => {
|
||||||
|
if (!React.isValidElement(node)) return
|
||||||
|
|
||||||
|
const element = node as React.ReactElement
|
||||||
|
const props = element.props as {
|
||||||
|
'id'?: string
|
||||||
|
'data-submenu-id'?: string
|
||||||
|
'children'?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.type === DropDrawerSub) {
|
||||||
|
const elementId = props.id || props['data-submenu-id']
|
||||||
|
|
||||||
|
if (elementId === targetId) {
|
||||||
|
React.Children.forEach(props.children, (child) => {
|
||||||
|
if (
|
||||||
|
React.isValidElement(child) &&
|
||||||
|
child.type === DropDrawerSubContent
|
||||||
|
) {
|
||||||
|
const subContentProps = child.props as {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
React.Children.forEach(
|
||||||
|
subContentProps.children,
|
||||||
|
(contentChild) => {
|
||||||
|
result.push(contentChild)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.children) {
|
||||||
|
React.Children.forEach(props.children, findSubmenuContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.Children.forEach(elements, findSubmenuContent)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get submenu content (always extract fresh to reflect state changes)
|
||||||
|
const getSubmenuContent = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
// Always extract fresh content to ensure state changes are reflected
|
||||||
|
const submenuContent = extractSubmenuContent(children, id)
|
||||||
|
return submenuContent
|
||||||
|
},
|
||||||
|
[children, extractSubmenuContent]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<SubmenuContext.Provider
|
||||||
|
value={{
|
||||||
|
activeSubmenu,
|
||||||
|
setActiveSubmenu: (id) => {
|
||||||
|
if (id === null) {
|
||||||
|
setActiveSubmenu(null)
|
||||||
|
setSubmenuTitle(null)
|
||||||
|
setSubmenuStack([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submenuTitle,
|
||||||
|
setSubmenuTitle,
|
||||||
|
navigateToSubmenu,
|
||||||
|
registerSubmenuContent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerContent
|
||||||
|
data-slot="drop-drawer-content"
|
||||||
|
className={cn(
|
||||||
|
'max-h-[90vh] w-full overflow-hidden max-w-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{activeSubmenu ? (
|
||||||
|
<>
|
||||||
|
<DrawerHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
className="hover:bg-muted/50 rounded-full "
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-5 w-5 text-main-view-fg/50" />
|
||||||
|
</button>
|
||||||
|
<DrawerTitle className="text-main-view-fg/80 text-sm">
|
||||||
|
{submenuTitle || 'Submenu'}
|
||||||
|
</DrawerTitle>
|
||||||
|
</div>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex-1 relative overflow-hidden max-h-[70vh]">
|
||||||
|
{/* Use AnimatePresence to handle exit animations */}
|
||||||
|
<AnimatePresence
|
||||||
|
initial={false}
|
||||||
|
mode="wait"
|
||||||
|
custom={animationDirection}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key={activeSubmenu || 'main'}
|
||||||
|
custom={animationDirection}
|
||||||
|
variants={ANIMATION_CONFIG.variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={ANIMATION_CONFIG.transition as any}
|
||||||
|
className="pb-6 space-y-1.5 w-full h-full overflow-hidden"
|
||||||
|
>
|
||||||
|
{activeSubmenu
|
||||||
|
? getSubmenuContent(activeSubmenu)
|
||||||
|
: children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DrawerHeader className="sr-only">
|
||||||
|
<DrawerTitle>Menu</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="overflow-hidden max-h-[70vh]">
|
||||||
|
<AnimatePresence
|
||||||
|
initial={false}
|
||||||
|
mode="wait"
|
||||||
|
custom={animationDirection}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
key="main-menu"
|
||||||
|
custom={animationDirection}
|
||||||
|
variants={ANIMATION_CONFIG.variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={ANIMATION_CONFIG.transition as any}
|
||||||
|
className="pb-6 space-y-1.5 w-full overflow-hidden"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DrawerContent>
|
||||||
|
</SubmenuContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubmenuContext.Provider
|
||||||
|
value={{
|
||||||
|
activeSubmenu,
|
||||||
|
setActiveSubmenu,
|
||||||
|
submenuTitle,
|
||||||
|
setSubmenuTitle,
|
||||||
|
navigateToSubmenu,
|
||||||
|
registerSubmenuContent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="drop-drawer-content"
|
||||||
|
sideOffset={4}
|
||||||
|
className={cn(
|
||||||
|
'max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[220px] overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</SubmenuContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
variant = 'default',
|
||||||
|
inset,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuItem> & {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useComponentSelection()
|
||||||
|
const { useGroupState } = useGroupDetection()
|
||||||
|
const { itemRef, isInsideGroup } = useGroupState()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
// If this item only has an icon (like a switch) and no other interactive content,
|
||||||
|
// don't handle clicks on the main area - let the icon handle everything
|
||||||
|
if (icon && !onClick && !onSelect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the click came from the icon area (where the Switch is)
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const iconContainer = (e.currentTarget as HTMLElement).querySelector(
|
||||||
|
'[data-icon-container]'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (iconContainer && iconContainer.contains(target)) {
|
||||||
|
// Don't handle the click if it came from the icon area
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onClick) onClick(e)
|
||||||
|
if (onSelect) onSelect(e as unknown as Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only wrap in DrawerClose if it's not a submenu item
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
data-slot="drop-drawer-item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-inset={inset}
|
||||||
|
data-disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
getMobileItemStyles(isInsideGroup, inset, variant, disabled),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">{children}</div>
|
||||||
|
{icon && (
|
||||||
|
<div className="flex-shrink-0" data-icon-container>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if this is inside a submenu
|
||||||
|
const isInSubmenu =
|
||||||
|
(props as Record<string, unknown>)['data-parent-submenu-id'] ||
|
||||||
|
(props as Record<string, unknown>)['data-parent-submenu']
|
||||||
|
|
||||||
|
if (isInSubmenu) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DrawerClose asChild>{content}</DrawerClose>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-slot="drop-drawer-item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-inset={inset}
|
||||||
|
className={className}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClick={onClick as React.MouseEventHandler<HTMLDivElement>}
|
||||||
|
variant={variant}
|
||||||
|
inset={inset}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4">
|
||||||
|
<div>{children}</div>
|
||||||
|
{icon && <div>{icon}</div>}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||||
|
const { isMobile } = useComponentSelection()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuSeparator
|
||||||
|
data-slot="drop-drawer-separator"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerLabel({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}:
|
||||||
|
| React.ComponentProps<typeof DropdownMenuLabel>
|
||||||
|
| React.ComponentProps<typeof DrawerTitle>) {
|
||||||
|
const { isMobile } = useComponentSelection()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<DrawerHeader className="p-0">
|
||||||
|
<DrawerTitle
|
||||||
|
data-slot="drop-drawer-label"
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium text-main-view-fg/60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuLabel
|
||||||
|
data-slot="drop-drawer-label"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerFooter({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerFooter> | React.ComponentProps<'div'>) {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<DrawerFooter
|
||||||
|
data-slot="drop-drawer-footer"
|
||||||
|
className={cn('p-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DrawerFooter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No direct equivalent in DropdownMenu, so we'll just render a div
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drop-drawer-footer"
|
||||||
|
className={cn('p-2', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerGroup({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
|
||||||
|
// Add separators between children on mobile
|
||||||
|
const childrenWithSeparators = React.useMemo(() => {
|
||||||
|
if (!isMobile) return children
|
||||||
|
|
||||||
|
const childArray = React.Children.toArray(children)
|
||||||
|
|
||||||
|
// Filter out any existing separators
|
||||||
|
const filteredChildren = childArray.filter(
|
||||||
|
(child) =>
|
||||||
|
React.isValidElement(child) && child.type !== DropDrawerSeparator
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add separators between items
|
||||||
|
return filteredChildren.flatMap((child, index) => {
|
||||||
|
if (index === filteredChildren.length - 1) return [child]
|
||||||
|
return [
|
||||||
|
child,
|
||||||
|
<div
|
||||||
|
key={`separator-${index}`}
|
||||||
|
className="bg-border h-px"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [children, isMobile])
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-drop-drawer-group
|
||||||
|
data-slot="drop-drawer-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
'bg-main-view-fg/2 border border-main-view-fg/4 mx-2 my-3 overflow-hidden rounded-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{childrenWithSeparators}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On desktop, use a div with proper role and attributes
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-drop-drawer-group
|
||||||
|
data-slot="drop-drawer-group"
|
||||||
|
role="group"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for managing submenu state on mobile
|
||||||
|
interface SubmenuContextType {
|
||||||
|
activeSubmenu: string | null
|
||||||
|
setActiveSubmenu: (id: string | null) => void
|
||||||
|
submenuTitle: string | null
|
||||||
|
setSubmenuTitle: (title: string | null) => void
|
||||||
|
navigateToSubmenu?: (id: string, title: string) => void
|
||||||
|
registerSubmenuContent?: (id: string, content: React.ReactNode[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubmenuContext = React.createContext<SubmenuContextType>({
|
||||||
|
activeSubmenu: null,
|
||||||
|
setActiveSubmenu: () => {},
|
||||||
|
submenuTitle: null,
|
||||||
|
setSubmenuTitle: () => {},
|
||||||
|
navigateToSubmenu: undefined,
|
||||||
|
registerSubmenuContent: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submenu components
|
||||||
|
// Counter for generating simple numeric IDs
|
||||||
|
let submenuIdCounter = 0
|
||||||
|
|
||||||
|
function DropDrawerSub({
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSub> & {
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
const { registerSubmenuContent } = React.useContext(SubmenuContext)
|
||||||
|
|
||||||
|
// Generate a simple numeric ID instead of using React.useId()
|
||||||
|
const [generatedId] = React.useState(() => `submenu-${submenuIdCounter++}`)
|
||||||
|
const submenuId = id || generatedId
|
||||||
|
|
||||||
|
// Extract submenu content to register with parent
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!registerSubmenuContent) return
|
||||||
|
|
||||||
|
// Find the SubContent within this Sub
|
||||||
|
const contentItems: React.ReactNode[] = []
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (React.isValidElement(child) && child.type === DropDrawerSubContent) {
|
||||||
|
// Add all children of the SubContent to the result
|
||||||
|
React.Children.forEach(
|
||||||
|
(child.props as { children?: React.ReactNode }).children,
|
||||||
|
(contentChild) => {
|
||||||
|
contentItems.push(contentChild)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the content with the parent
|
||||||
|
if (contentItems.length > 0) {
|
||||||
|
registerSubmenuContent(submenuId, contentItems)
|
||||||
|
}
|
||||||
|
}, [children, registerSubmenuContent, submenuId])
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// For mobile, we'll use the context to manage submenu state
|
||||||
|
// Process children to pass the submenu ID to the trigger and content
|
||||||
|
const processedChildren = React.Children.map(children, (child) => {
|
||||||
|
if (!React.isValidElement(child)) return child
|
||||||
|
|
||||||
|
if (child.type === DropDrawerSubTrigger) {
|
||||||
|
return React.cloneElement(
|
||||||
|
child as React.ReactElement,
|
||||||
|
{
|
||||||
|
...(child.props as object),
|
||||||
|
'data-parent-submenu-id': submenuId,
|
||||||
|
'data-submenu-id': submenuId,
|
||||||
|
// Use only data attributes, not custom props
|
||||||
|
'data-parent-submenu': submenuId,
|
||||||
|
'data-submenu-title': title,
|
||||||
|
} as React.HTMLAttributes<HTMLElement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.type === DropDrawerSubContent) {
|
||||||
|
return React.cloneElement(
|
||||||
|
child as React.ReactElement,
|
||||||
|
{
|
||||||
|
...(child.props as object),
|
||||||
|
'data-parent-submenu-id': submenuId,
|
||||||
|
'data-submenu-id': submenuId,
|
||||||
|
// Use only data attributes, not custom props
|
||||||
|
'data-parent-submenu': submenuId,
|
||||||
|
} as React.HTMLAttributes<HTMLElement>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return child
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drop-drawer-sub"
|
||||||
|
data-submenu-id={submenuId}
|
||||||
|
id={submenuId}
|
||||||
|
>
|
||||||
|
{processedChildren}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For desktop, use the standard DropdownMenuSub
|
||||||
|
return <DropdownMenuSub {...props}>{children}</DropdownMenuSub>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useComponentSelection()
|
||||||
|
const { navigateToSubmenu } = React.useContext(SubmenuContext)
|
||||||
|
const { useGroupState } = useGroupDetection()
|
||||||
|
const { itemRef, isInsideGroup } = useGroupState()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Find the parent submenu ID
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Get the closest parent with data-submenu-id attribute
|
||||||
|
const element = e.currentTarget as HTMLElement
|
||||||
|
let submenuId: string | null = null
|
||||||
|
|
||||||
|
// First check if the element itself has the data attribute
|
||||||
|
if (element.closest('[data-submenu-id]')) {
|
||||||
|
const closestElement = element.closest('[data-submenu-id]')
|
||||||
|
const id = closestElement?.getAttribute('data-submenu-id')
|
||||||
|
if (id) {
|
||||||
|
submenuId = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, try props
|
||||||
|
if (!submenuId) {
|
||||||
|
submenuId =
|
||||||
|
((props as Record<string, unknown>)[
|
||||||
|
'data-parent-submenu-id'
|
||||||
|
] as string) ||
|
||||||
|
((props as Record<string, unknown>)['data-parent-submenu'] as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submenuId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the title - first try data attribute, then children, then fallback
|
||||||
|
const dataTitle = (props as Record<string, unknown>)[
|
||||||
|
'data-submenu-title'
|
||||||
|
] as string
|
||||||
|
const title =
|
||||||
|
dataTitle || (typeof children === 'string' ? children : 'Submenu')
|
||||||
|
|
||||||
|
// Navigate to the submenu
|
||||||
|
if (navigateToSubmenu) {
|
||||||
|
navigateToSubmenu(submenuId, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine onClick handlers
|
||||||
|
const combinedOnClick = (e: React.MouseEvent) => {
|
||||||
|
// Call the original onClick if provided
|
||||||
|
const typedProps = props as Record<string, unknown>
|
||||||
|
if (typedProps.onClick) {
|
||||||
|
const originalOnClick =
|
||||||
|
typedProps.onClick as React.MouseEventHandler<HTMLDivElement>
|
||||||
|
originalOnClick(e as React.MouseEvent<HTMLDivElement>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call our navigation handler
|
||||||
|
handleClick(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove onClick from props to avoid duplicate handlers
|
||||||
|
const { ...restProps } = props as Record<string, unknown>
|
||||||
|
|
||||||
|
// Don't wrap in DrawerClose for submenu triggers
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
data-slot="drop-drawer-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(getMobileItemStyles(isInsideGroup, inset), className)}
|
||||||
|
onClick={combinedOnClick}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 w-full">{children}</div>
|
||||||
|
<ChevronRightIcon className="h-5 w-5 text-main-view-fg/50 " />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
data-slot="drop-drawer-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={className}
|
||||||
|
inset={inset}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropDrawerSubContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||||
|
const { isMobile } = useDropDrawerContext()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// For mobile, we don't render the content directly
|
||||||
|
// It will be rendered by the DropDrawerContent component when active
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
data-slot="drop-drawer-sub-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropDrawer,
|
||||||
|
DropDrawerContent,
|
||||||
|
DropDrawerFooter,
|
||||||
|
DropDrawerGroup,
|
||||||
|
DropDrawerItem,
|
||||||
|
DropDrawerLabel,
|
||||||
|
DropDrawerSeparator,
|
||||||
|
DropDrawerSub,
|
||||||
|
DropDrawerSubContent,
|
||||||
|
DropDrawerSubTrigger,
|
||||||
|
DropDrawerTrigger,
|
||||||
|
}
|
||||||
42
web-app/src/components/ui/radio-group.tsx
Normal file
42
web-app/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||||
|
import { CircleIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn('grid gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'aspect-square size-4 rounded-full border border-main-view-fg/20 text-main-view-fg ring-offset-main-view focus:outline-none focus-visible:ring-2 focus-visible:ring-main-view-fg/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<CircleIcon className="size-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@ -483,7 +483,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
|
|||||||
initialMessage={initialMessage}
|
initialMessage={initialMessage}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
setDropdownToolsAvailable(isOpen)
|
setDropdownToolsAvailable(isOpen)
|
||||||
setTooltipToolsAvailable(false)
|
if (isOpen) {
|
||||||
|
setTooltipToolsAvailable(false)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(isOpen, toolsCount) => {
|
{(isOpen, toolsCount) => {
|
||||||
|
|||||||
@ -471,7 +471,10 @@ const DropdownModelProvider = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span className="truncate text-main-view-fg/80 text-sm">
|
<span
|
||||||
|
className="truncate text-main-view-fg/80 text-sm"
|
||||||
|
title={searchableModel.model.id}
|
||||||
|
>
|
||||||
{searchableModel.model.id}
|
{searchableModel.model.id}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropDrawer,
|
||||||
DropdownMenuContent,
|
DropDrawerContent,
|
||||||
DropdownMenuItem,
|
DropDrawerItem,
|
||||||
DropdownMenuLabel,
|
DropDrawerSub,
|
||||||
DropdownMenuSeparator,
|
DropDrawerLabel,
|
||||||
DropdownMenuTrigger,
|
DropDrawerSubContent,
|
||||||
} from '@/components/ui/dropdown-menu'
|
DropDrawerSeparator,
|
||||||
|
DropDrawerSubTrigger,
|
||||||
|
DropDrawerTrigger,
|
||||||
|
DropDrawerGroup,
|
||||||
|
} from '@/components/ui/dropdrawer'
|
||||||
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
import { useThreads } from '@/hooks/useThreads'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
@ -15,6 +21,7 @@ import { useToolAvailable } from '@/hooks/useToolAvailable'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useAppState } from '@/hooks/useAppState'
|
import { useAppState } from '@/hooks/useAppState'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface DropdownToolsAvailableProps {
|
interface DropdownToolsAvailableProps {
|
||||||
children: (isOpen: boolean, toolsCount: number) => React.ReactNode
|
children: (isOpen: boolean, toolsCount: number) => React.ReactNode
|
||||||
@ -82,6 +89,23 @@ export default function DropdownToolsAvailable({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDisableAllServerTools = (
|
||||||
|
serverName: string,
|
||||||
|
disable: boolean
|
||||||
|
) => {
|
||||||
|
const allToolsByServer = getToolsByServer()
|
||||||
|
const serverTools = allToolsByServer[serverName] || []
|
||||||
|
serverTools.forEach((tool) => {
|
||||||
|
handleToolToggle(tool.name, !disable)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const areAllServerToolsDisabled = (serverName: string): boolean => {
|
||||||
|
const allToolsByServer = getToolsByServer()
|
||||||
|
const serverTools = allToolsByServer[serverName] || []
|
||||||
|
return serverTools.every((tool) => !isToolChecked(tool.name))
|
||||||
|
}
|
||||||
|
|
||||||
const getEnabledToolsCount = (): number => {
|
const getEnabledToolsCount = (): number => {
|
||||||
const disabledTools = initialMessage
|
const disabledTools = initialMessage
|
||||||
? getDefaultDisabledTools()
|
? getDefaultDisabledTools()
|
||||||
@ -91,69 +115,153 @@ export default function DropdownToolsAvailable({
|
|||||||
return tools.filter((tool) => !disabledTools.includes(tool.name)).length
|
return tools.filter((tool) => !disabledTools.includes(tool.name)).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getToolsByServer = () => {
|
||||||
|
const toolsByServer = tools.reduce(
|
||||||
|
(acc, tool) => {
|
||||||
|
if (!acc[tool.server]) {
|
||||||
|
acc[tool.server] = []
|
||||||
|
}
|
||||||
|
acc[tool.server].push(tool)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof tools>
|
||||||
|
)
|
||||||
|
|
||||||
|
return toolsByServer
|
||||||
|
}
|
||||||
|
|
||||||
const renderTrigger = () => children(isOpen, getEnabledToolsCount())
|
const renderTrigger = () => children(isOpen, getEnabledToolsCount())
|
||||||
|
|
||||||
if (tools.length === 0) {
|
if (tools.length === 0) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
<DropDrawer onOpenChange={handleOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>{renderTrigger()}</DropdownMenuTrigger>
|
<DropDrawerTrigger asChild>{renderTrigger()}</DropDrawerTrigger>
|
||||||
<DropdownMenuContent align="start" className="max-w-64">
|
<DropDrawerContent align="start" className="max-w-64">
|
||||||
<DropdownMenuItem disabled>{t('common:noToolsAvailable')}</DropdownMenuItem>
|
<DropDrawerItem disabled>
|
||||||
</DropdownMenuContent>
|
{t('common:noToolsAvailable')}
|
||||||
</DropdownMenu>
|
</DropDrawerItem>
|
||||||
|
</DropDrawerContent>
|
||||||
|
</DropDrawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const toolsByServer = getToolsByServer()
|
||||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
|
||||||
<DropdownMenuTrigger asChild>{renderTrigger()}</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
return (
|
||||||
|
<DropDrawer onOpenChange={handleOpenChange}>
|
||||||
|
<DropDrawerTrigger asChild>{renderTrigger()}</DropDrawerTrigger>
|
||||||
|
<DropDrawerContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
className="max-w-64 backdrop-blur-xl bg-main-view"
|
className="bg-main-view !overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="flex items-center gap-2 sticky -top-1 z-10 px-4 pl-2 py-2 ">
|
<DropDrawerLabel className="flex items-center gap-2 sticky -top-1 z-10 px-4 pl-2 py-1">
|
||||||
Available Tools
|
Available Tools
|
||||||
</DropdownMenuLabel>
|
</DropDrawerLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropDrawerSeparator />
|
||||||
<div className="max-h-64 overflow-y-auto">
|
<div className="max-h-64 overflow-y-auto">
|
||||||
{tools.map((tool) => {
|
<DropDrawerGroup>
|
||||||
const isChecked = isToolChecked(tool.name)
|
{Object.entries(toolsByServer).map(([serverName, serverTools]) => (
|
||||||
return (
|
<DropDrawerSub
|
||||||
<div
|
id={`server-${serverName}`}
|
||||||
key={tool.name}
|
key={serverName}
|
||||||
className="py-2 hover:bg-main-view-fg/5 hover:backdrop-blur-2xl rounded-sm px-2 mx-auto w-full"
|
title={serverName}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-center gap-3">
|
<DropDrawerSubTrigger className="py-2 hover:bg-main-view-fg/5 hover:backdrop-blur-2xl rounded-sm px-2 mx-auto w-full">
|
||||||
<div className="flex items-start justify-between gap-4 w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="overflow-hidden w-full flex flex-col ">
|
<span className="text-sm text-main-view-fg/80">
|
||||||
<div className="truncate">
|
{serverName}
|
||||||
<span className="text-sm font-medium" title={tool.name}>
|
</span>
|
||||||
{tool.name}
|
<span className="text-xs text-main-view-fg/50 inline-flex items-center mr-1 border border-main-view-fg/20 px-1 rounded-sm">
|
||||||
</span>
|
{
|
||||||
</div>
|
serverTools.filter((tool) => isToolChecked(tool.name))
|
||||||
{tool.description && (
|
.length
|
||||||
<p className="text-xs text-main-view-fg/70 mt-1 line-clamp-2">
|
}
|
||||||
{tool.description}
|
</span>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 mx-auto">
|
|
||||||
<Switch
|
|
||||||
checked={isChecked}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleToolToggle(tool.name, checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DropDrawerSubTrigger>
|
||||||
</div>
|
<DropDrawerSubContent className="max-w-64 max-h-70 w-full overflow-hidden">
|
||||||
)
|
<DropDrawerGroup>
|
||||||
})}
|
{serverTools.length > 1 && (
|
||||||
|
<div className="sticky top-0 z-10 bg-main-view border-b border-main-view-fg/10 px-4 md:px-2 pr-2 py-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-main-view-fg/70">
|
||||||
|
Disable All Tools
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2',
|
||||||
|
serverTools.length > 5
|
||||||
|
? 'mr-3 md:mr-1.5'
|
||||||
|
: 'mr-2 md:mr-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={!areAllServerToolsDisabled(serverName)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleDisableAllServerTools(serverName, !checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{serverTools.map((tool) => {
|
||||||
|
const isChecked = isToolChecked(tool.name)
|
||||||
|
return (
|
||||||
|
<DropDrawerItem
|
||||||
|
onClick={(e) => {
|
||||||
|
handleToolToggle(tool.name, !isChecked)
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
onSelect={(e) => {
|
||||||
|
handleToolToggle(tool.name, !isChecked)
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
key={tool.name}
|
||||||
|
className="mt-1 first:mt-0 py-1.5"
|
||||||
|
icon={
|
||||||
|
<Switch
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
console.log('checked', checked)
|
||||||
|
handleToolToggle(tool.name, checked)
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden flex flex-col items-start ">
|
||||||
|
<div className="truncate">
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium text-main-view-fg"
|
||||||
|
title={tool.name}
|
||||||
|
>
|
||||||
|
{tool.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tool.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-main-view-fg/70 mt-1 line-clamp-1"
|
||||||
|
title={tool.description}
|
||||||
|
>
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropDrawerItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DropDrawerGroup>
|
||||||
|
</DropDrawerSubContent>
|
||||||
|
</DropDrawerSub>
|
||||||
|
))}
|
||||||
|
</DropDrawerGroup>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropDrawerContent>
|
||||||
</DropdownMenu>
|
</DropDrawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,15 +21,15 @@ export default function GlobalError({ error }: GlobalErrorProps) {
|
|||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M6 8H6.01M6 16H6.01M6 12H18C20.2091 12 22 10.2091 22 8C22 5.79086 20.2091 4 18 4H6C3.79086 4 2 5.79086 2 8C2 10.2091 3.79086 12 6 12ZM6 12C3.79086 12 2 13.7909 2 16C2 18.2091 3.79086 20 6 20H14"
|
d="M6 8H6.01M6 16H6.01M6 12H18C20.2091 12 22 10.2091 22 8C22 5.79086 20.2091 4 18 4H6C3.79086 4 2 5.79086 2 8C2 10.2091 3.79086 12 6 12ZM6 12C3.79086 12 2 13.7909 2 16C2 18.2091 3.79086 20 6 20H14"
|
||||||
stroke-width="2"
|
strokeWidth="2"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke-linejoin="round"
|
strokeLinejoin="round"
|
||||||
></path>
|
></path>
|
||||||
<path
|
<path
|
||||||
d="M17 16L22 21M22 16L17 21"
|
d="M17 16L22 21M22 16L17 21"
|
||||||
stroke-width="2"
|
strokeWidth="2"
|
||||||
stroke-linecap="round"
|
strokeLinecap="round"
|
||||||
stroke-linejoin="round"
|
strokeLinejoin="round"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,13 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { IconPlus, IconTrash, IconGripVertical } from '@tabler/icons-react'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconTrash,
|
||||||
|
IconGripVertical,
|
||||||
|
IconCodeDots,
|
||||||
|
} from '@tabler/icons-react'
|
||||||
import { MCPServerConfig } from '@/hooks/useMCPServers'
|
import { MCPServerConfig } from '@/hooks/useMCPServers'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import {
|
import {
|
||||||
@ -27,6 +33,8 @@ import {
|
|||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import CodeEditor from '@uiw/react-textarea-code-editor'
|
||||||
|
import '@uiw/react-textarea-code-editor/dist.css'
|
||||||
|
|
||||||
// Sortable argument item component
|
// Sortable argument item component
|
||||||
function SortableArgItem({
|
function SortableArgItem({
|
||||||
@ -114,13 +122,34 @@ export default function AddEditMCPServer({
|
|||||||
const [args, setArgs] = useState<string[]>([''])
|
const [args, setArgs] = useState<string[]>([''])
|
||||||
const [envKeys, setEnvKeys] = useState<string[]>([''])
|
const [envKeys, setEnvKeys] = useState<string[]>([''])
|
||||||
const [envValues, setEnvValues] = useState<string[]>([''])
|
const [envValues, setEnvValues] = useState<string[]>([''])
|
||||||
|
const [transportType, setTransportType] = useState<'stdio' | 'http' | 'sse'>(
|
||||||
|
'stdio'
|
||||||
|
)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [headerKeys, setHeaderKeys] = useState<string[]>([''])
|
||||||
|
const [headerValues, setHeaderValues] = useState<string[]>([''])
|
||||||
|
const [timeout, setTimeout] = useState('')
|
||||||
|
const [isToggled, setIsToggled] = useState(false)
|
||||||
|
const [jsonContent, setJsonContent] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Reset form when modal opens/closes or editing key changes
|
// Reset form when modal opens/closes or editing key changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && editingKey && initialData) {
|
if (open && editingKey && initialData) {
|
||||||
setServerName(editingKey)
|
setServerName(editingKey)
|
||||||
setCommand(initialData.command)
|
setCommand(initialData.command || '')
|
||||||
|
setUrl(initialData.url || '')
|
||||||
|
setTimeout(initialData.timeout ? initialData.timeout.toString() : '')
|
||||||
setArgs(initialData.args?.length > 0 ? initialData.args : [''])
|
setArgs(initialData.args?.length > 0 ? initialData.args : [''])
|
||||||
|
setTransportType(initialData?.type || 'stdio')
|
||||||
|
|
||||||
|
// Initialize JSON content for toggle mode
|
||||||
|
try {
|
||||||
|
const jsonData = { [editingKey]: initialData }
|
||||||
|
setJsonContent(JSON.stringify(jsonData, null, 2))
|
||||||
|
} catch {
|
||||||
|
setJsonContent('')
|
||||||
|
}
|
||||||
|
|
||||||
if (initialData.env) {
|
if (initialData.env) {
|
||||||
// Convert env object to arrays of keys and values
|
// Convert env object to arrays of keys and values
|
||||||
@ -130,6 +159,17 @@ export default function AddEditMCPServer({
|
|||||||
setEnvKeys(keys.length > 0 ? keys : [''])
|
setEnvKeys(keys.length > 0 ? keys : [''])
|
||||||
setEnvValues(values.length > 0 ? values : [''])
|
setEnvValues(values.length > 0 ? values : [''])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialData.headers) {
|
||||||
|
// Convert headers object to arrays of keys and values
|
||||||
|
const headerKeysList = Object.keys(initialData.headers)
|
||||||
|
const headerValuesList = headerKeysList.map(
|
||||||
|
(key) => initialData.headers![key]
|
||||||
|
)
|
||||||
|
|
||||||
|
setHeaderKeys(headerKeysList.length > 0 ? headerKeysList : [''])
|
||||||
|
setHeaderValues(headerValuesList.length > 0 ? headerValuesList : [''])
|
||||||
|
}
|
||||||
} else if (open) {
|
} else if (open) {
|
||||||
// Add mode - reset form
|
// Add mode - reset form
|
||||||
resetForm()
|
resetForm()
|
||||||
@ -139,9 +179,17 @@ export default function AddEditMCPServer({
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setServerName('')
|
setServerName('')
|
||||||
setCommand('')
|
setCommand('')
|
||||||
|
setUrl('')
|
||||||
|
setTimeout('')
|
||||||
setArgs([''])
|
setArgs([''])
|
||||||
setEnvKeys([''])
|
setEnvKeys([''])
|
||||||
setEnvValues([''])
|
setEnvValues([''])
|
||||||
|
setHeaderKeys([''])
|
||||||
|
setHeaderValues([''])
|
||||||
|
setTransportType('stdio')
|
||||||
|
setIsToggled(false)
|
||||||
|
setJsonContent('')
|
||||||
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddArg = () => {
|
const handleAddArg = () => {
|
||||||
@ -201,7 +249,57 @@ export default function AddEditMCPServer({
|
|||||||
setEnvValues(newValues)
|
setEnvValues(newValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddHeader = () => {
|
||||||
|
setHeaderKeys([...headerKeys, ''])
|
||||||
|
setHeaderValues([...headerValues, ''])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveHeader = (index: number) => {
|
||||||
|
const newKeys = [...headerKeys]
|
||||||
|
const newValues = [...headerValues]
|
||||||
|
newKeys.splice(index, 1)
|
||||||
|
newValues.splice(index, 1)
|
||||||
|
setHeaderKeys(newKeys.length > 0 ? newKeys : [''])
|
||||||
|
setHeaderValues(newValues.length > 0 ? newValues : [''])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderKeyChange = (index: number, value: string) => {
|
||||||
|
const newKeys = [...headerKeys]
|
||||||
|
newKeys[index] = value
|
||||||
|
setHeaderKeys(newKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHeaderValueChange = (index: number, value: string) => {
|
||||||
|
const newValues = [...headerValues]
|
||||||
|
newValues[index] = value
|
||||||
|
setHeaderValues(newValues)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
// Handle JSON mode
|
||||||
|
if (isToggled) {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(jsonContent)
|
||||||
|
// Validate that it's an object with server configurations
|
||||||
|
if (typeof parsedData !== 'object' || parsedData === null) {
|
||||||
|
setError(t('mcp-servers:editJson.errorFormat'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// For each server in the JSON, call onSave
|
||||||
|
Object.entries(parsedData).forEach(([serverName, config]) => {
|
||||||
|
onSave(serverName.trim(), config as MCPServerConfig)
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
resetForm()
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
setError(t('mcp-servers:editJson.errorFormat'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form mode
|
||||||
// Convert env arrays to object
|
// Convert env arrays to object
|
||||||
const envObj: Record<string, string> = {}
|
const envObj: Record<string, string> = {}
|
||||||
envKeys.forEach((key, index) => {
|
envKeys.forEach((key, index) => {
|
||||||
@ -211,13 +309,28 @@ export default function AddEditMCPServer({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Convert headers arrays to object
|
||||||
|
const headersObj: Record<string, string> = {}
|
||||||
|
headerKeys.forEach((key, index) => {
|
||||||
|
const keyName = key.trim()
|
||||||
|
if (keyName !== '') {
|
||||||
|
headersObj[keyName] = headerValues[index]?.trim() || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Filter out empty args
|
// Filter out empty args
|
||||||
const filteredArgs = args.map((arg) => arg.trim()).filter((arg) => arg)
|
const filteredArgs = args.map((arg) => arg.trim()).filter((arg) => arg)
|
||||||
|
|
||||||
const config: MCPServerConfig = {
|
const config: MCPServerConfig = {
|
||||||
command: command.trim(),
|
command: transportType === 'stdio' ? command.trim() : '',
|
||||||
args: filteredArgs,
|
args: transportType === 'stdio' ? filteredArgs : [],
|
||||||
env: envObj,
|
env: transportType === 'stdio' ? envObj : {},
|
||||||
|
type: transportType,
|
||||||
|
...(transportType !== 'stdio' && {
|
||||||
|
url: url.trim(),
|
||||||
|
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
|
||||||
|
timeout: timeout.trim() !== '' ? parseInt(timeout) : undefined,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverName.trim() !== '') {
|
if (serverName.trim() !== '') {
|
||||||
@ -229,121 +342,297 @@ export default function AddEditMCPServer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle className="flex items-center justify-between">
|
||||||
{editingKey
|
<span>
|
||||||
? t('mcp-servers:editServer')
|
{editingKey
|
||||||
: t('mcp-servers:addServer')}
|
? t('mcp-servers:editServer')
|
||||||
|
: t('mcp-servers:addServer')}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out',
|
||||||
|
isToggled && 'bg-main-view-fg/10 text-accent'
|
||||||
|
)}
|
||||||
|
title="Add server by JSON"
|
||||||
|
onClick={() => setIsToggled(!isToggled)}
|
||||||
|
>
|
||||||
|
<IconCodeDots className="h-5 w-5 cursor-pointer transition-colors duration-200" />
|
||||||
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
{isToggled ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<label className="text-sm mb-2 inline-block">
|
<div className="space-y-2">
|
||||||
{t('mcp-servers:serverName')}
|
<label className="text-sm mb-2 inline-block">
|
||||||
</label>
|
{t('mcp-servers:editJson.placeholder')}
|
||||||
<Input
|
</label>
|
||||||
value={serverName}
|
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
|
||||||
onChange={(e) => setServerName(e.target.value)}
|
<CodeEditor
|
||||||
placeholder={t('mcp-servers:enterServerName')}
|
value={jsonContent}
|
||||||
autoFocus
|
language="json"
|
||||||
/>
|
placeholder={`{
|
||||||
</div>
|
"serverName": {
|
||||||
|
"command": "command",
|
||||||
<div className="space-y-2">
|
"args": ["arg1", "arg2"],
|
||||||
<label className="text-sm mb-2 inline-block">
|
"env": {
|
||||||
{t('mcp-servers:command')}
|
"KEY": "value"
|
||||||
</label>
|
}
|
||||||
<Input
|
}
|
||||||
value={command}
|
}`}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder={t('mcp-servers:enterCommand')}
|
setJsonContent(e.target.value)
|
||||||
/>
|
setError(null)
|
||||||
</div>
|
}}
|
||||||
|
onPaste={() => setError(null)}
|
||||||
<div className="space-y-2">
|
style={{
|
||||||
<div className="flex items-center justify-between">
|
fontFamily: 'ui-monospace',
|
||||||
<label className="text-sm">{t('mcp-servers:arguments')}</label>
|
backgroundColor: 'transparent',
|
||||||
<div
|
wordBreak: 'break-all',
|
||||||
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
overflowWrap: 'anywhere',
|
||||||
onClick={handleAddArg}
|
whiteSpace: 'pre-wrap',
|
||||||
>
|
}}
|
||||||
<IconPlus size={18} className="text-main-view-fg/60" />
|
className="w-full !text-sm min-h-[300px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{error && <div className="text-destructive text-sm">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm mb-2 inline-block">
|
||||||
|
{t('mcp-servers:serverName')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
placeholder={t('mcp-servers:enterServerName')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext
|
<div className="space-y-2">
|
||||||
sensors={sensors}
|
<label className="text-sm mb-2 inline-block">
|
||||||
collisionDetection={closestCenter}
|
Transport Type
|
||||||
onDragEnd={(event) => {
|
</label>
|
||||||
const { active, over } = event
|
<RadioGroup
|
||||||
if (active.id !== over?.id) {
|
value={transportType}
|
||||||
const oldIndex = parseInt(active.id.toString())
|
onValueChange={(value) =>
|
||||||
const newIndex = parseInt(over?.id.toString() || '0')
|
setTransportType(value as 'http' | 'sse')
|
||||||
handleReorderArgs(oldIndex, newIndex)
|
|
||||||
}
|
}
|
||||||
}}
|
className="flex gap-6"
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={args.map((_, index) => index)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
>
|
||||||
{args.map((arg, index) => (
|
<div className="flex items-center space-x-2">
|
||||||
<SortableArgItem
|
<RadioGroupItem value="stdio" id="stdio" />
|
||||||
key={index}
|
<label
|
||||||
id={index}
|
htmlFor="stdio"
|
||||||
value={arg}
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
onChange={(value) => handleArgChange(index, value)}
|
>
|
||||||
onRemove={() => handleRemoveArg(index)}
|
STDIO
|
||||||
canRemove={args.length > 1}
|
</label>
|
||||||
placeholder={t('mcp-servers:argument', {
|
</div>
|
||||||
index: index + 1,
|
<div className="flex items-center space-x-2">
|
||||||
})}
|
<RadioGroupItem value="http" id="http" />
|
||||||
/>
|
<label
|
||||||
))}
|
htmlFor="http"
|
||||||
</SortableContext>
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
</DndContext>
|
>
|
||||||
</div>
|
HTTP
|
||||||
|
</label>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center space-x-2">
|
||||||
<label className="text-sm">{t('mcp-servers:envVars')}</label>
|
<RadioGroupItem value="sse" id="sse" />
|
||||||
<div
|
<label
|
||||||
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
htmlFor="sse"
|
||||||
onClick={handleAddEnv}
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
<IconPlus size={18} className="text-main-view-fg/60" />
|
SSE
|
||||||
</div>
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{envKeys.map((key, index) => (
|
{transportType === 'stdio' ? (
|
||||||
<div key={`env-${index}`} className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm mb-2 inline-block">
|
||||||
|
{t('mcp-servers:command')}
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={key}
|
value={command}
|
||||||
onChange={(e) => handleEnvKeyChange(index, e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
placeholder={t('mcp-servers:key')}
|
placeholder={t('mcp-servers:enterCommand')}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm mb-2 inline-block">URL</label>
|
||||||
<Input
|
<Input
|
||||||
value={envValues[index] || ''}
|
value={url}
|
||||||
onChange={(e) => handleEnvValueChange(index, e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
placeholder={t('mcp-servers:value')}
|
placeholder="Enter URL"
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
{envKeys.length > 1 && (
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transportType === 'stdio' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t('mcp-servers:arguments')}
|
||||||
|
</label>
|
||||||
<div
|
<div
|
||||||
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
onClick={() => handleRemoveEnv(index)}
|
onClick={handleAddArg}
|
||||||
>
|
>
|
||||||
<IconTrash size={18} className="text-destructive" />
|
<IconPlus size={18} className="text-main-view-fg/60" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (active.id !== over?.id) {
|
||||||
|
const oldIndex = parseInt(active.id.toString())
|
||||||
|
const newIndex = parseInt(over?.id.toString() || '0')
|
||||||
|
handleReorderArgs(oldIndex, newIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={args.map((_, index) => index)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{args.map((arg, index) => (
|
||||||
|
<SortableArgItem
|
||||||
|
key={index}
|
||||||
|
id={index}
|
||||||
|
value={arg}
|
||||||
|
onChange={(value) => handleArgChange(index, value)}
|
||||||
|
onRemove={() => handleRemoveArg(index)}
|
||||||
|
canRemove={args.length > 1}
|
||||||
|
placeholder={t('mcp-servers:argument', {
|
||||||
|
index: index + 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{transportType === 'stdio' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm">{t('mcp-servers:envVars')}</label>
|
||||||
|
<div
|
||||||
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
|
onClick={handleAddEnv}
|
||||||
|
>
|
||||||
|
<IconPlus size={18} className="text-main-view-fg/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envKeys.map((key, index) => (
|
||||||
|
<div key={`env-${index}`} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={key}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleEnvKeyChange(index, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('mcp-servers:key')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={envValues[index] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleEnvValueChange(index, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t('mcp-servers:value')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{envKeys.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
|
onClick={() => handleRemoveEnv(index)}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} className="text-destructive" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(transportType === 'http' || transportType === 'sse') && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm">Headers</label>
|
||||||
|
<div
|
||||||
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
|
onClick={handleAddHeader}
|
||||||
|
>
|
||||||
|
<IconPlus size={18} className="text-main-view-fg/60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headerKeys.map((key, index) => (
|
||||||
|
<div
|
||||||
|
key={`header-${index}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={key}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleHeaderKeyChange(index, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Header name"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={headerValues[index] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleHeaderValueChange(index, e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Header value"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{headerKeys.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
|
onClick={() => handleRemoveHeader(index)}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} className="text-destructive" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm mb-2 inline-block">
|
||||||
|
Timeout (seconds)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={timeout}
|
||||||
|
onChange={(e) => setTimeout(e.target.value)}
|
||||||
|
placeholder="Enter timeout in seconds"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<Button variant="link" onClick={() => onOpenChange(false)}>
|
||||||
|
{t('common:cancel')}
|
||||||
|
</Button>
|
||||||
<Button onClick={handleSave}>{t('mcp-servers:save')}</Button>
|
<Button onClick={handleSave}>{t('mcp-servers:save')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -43,19 +43,9 @@ export default function EditJsonMCPserver({
|
|||||||
}
|
}
|
||||||
}, [open, initialData, t])
|
}, [open, initialData, t])
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent) => {
|
const handlePaste = () => {
|
||||||
const pastedText = e.clipboardData.getData('text')
|
// Clear any existing errors when pasting
|
||||||
try {
|
setError(null)
|
||||||
const parsedJson = JSON.parse(pastedText)
|
|
||||||
const prettifiedJson = JSON.stringify(parsedJson, null, 2)
|
|
||||||
e.preventDefault()
|
|
||||||
setJsonContent(prettifiedJson)
|
|
||||||
setError(null)
|
|
||||||
} catch (error) {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(t('mcp-servers:editJson.errorPaste'))
|
|
||||||
console.error('Paste error:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@ -80,7 +70,18 @@ export default function EditJsonMCPserver({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
|
<div className="border border-main-view-fg/10 rounded-md !overflow-hidden">
|
||||||
|
<style>{`
|
||||||
|
.w-tc-editor textarea {
|
||||||
|
word-break: break-all !important;
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
.w-tc-editor .token.string {
|
||||||
|
word-break: break-all !important;
|
||||||
|
overflow-wrap: anywhere !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={jsonContent}
|
value={jsonContent}
|
||||||
language="json"
|
language="json"
|
||||||
@ -90,8 +91,11 @@ export default function EditJsonMCPserver({
|
|||||||
style={{
|
style={{
|
||||||
fontFamily: 'ui-monospace',
|
fontFamily: 'ui-monospace',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
}}
|
}}
|
||||||
className="w-full !text-sm "
|
className="w-full !text-sm overflow-hidden break-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-destructive text-sm">{error}</div>}
|
{error && <div className="text-destructive text-sm">{error}</div>}
|
||||||
|
|||||||
180
web-app/src/hooks/__tests__/useTools.test.ts
Normal file
180
web-app/src/hooks/__tests__/useTools.test.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { SystemEvent } from '@/types/events'
|
||||||
|
|
||||||
|
// Mock functions
|
||||||
|
const mockGetTools = vi.fn()
|
||||||
|
const mockUpdateTools = vi.fn()
|
||||||
|
const mockListen = vi.fn()
|
||||||
|
const mockUnsubscribe = vi.fn()
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
vi.mock('@/services/mcp', () => ({
|
||||||
|
getTools: mockGetTools,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../useAppState', () => ({
|
||||||
|
useAppState: () => ({
|
||||||
|
updateTools: mockUpdateTools,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/event', () => ({
|
||||||
|
listen: mockListen,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useTools', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockListen.mockResolvedValue(mockUnsubscribe)
|
||||||
|
mockGetTools.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call getTools and updateTools on mount', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const mockTools = [
|
||||||
|
{ name: 'test-tool', description: 'A test tool' },
|
||||||
|
{ name: 'another-tool', description: 'Another test tool' },
|
||||||
|
]
|
||||||
|
mockGetTools.mockResolvedValue(mockTools)
|
||||||
|
|
||||||
|
renderHook(() => useTools())
|
||||||
|
|
||||||
|
// Wait for async operations to complete
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockUpdateTools).toHaveBeenCalledWith(mockTools)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set up event listener for MCP_UPDATE', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
renderHook(() => useTools())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockListen).toHaveBeenCalledWith(
|
||||||
|
SystemEvent.MCP_UPDATE,
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call setTools when MCP_UPDATE event is triggered', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const mockTools = [{ name: 'updated-tool', description: 'Updated tool' }]
|
||||||
|
mockGetTools.mockResolvedValue(mockTools)
|
||||||
|
|
||||||
|
let eventCallback: () => void
|
||||||
|
|
||||||
|
mockListen.mockImplementation((_event, callback) => {
|
||||||
|
eventCallback = callback
|
||||||
|
return Promise.resolve(mockUnsubscribe)
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useTools())
|
||||||
|
|
||||||
|
// Wait for initial setup
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear the initial calls
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGetTools.mockResolvedValue(mockTools)
|
||||||
|
|
||||||
|
// Trigger the event
|
||||||
|
await act(async () => {
|
||||||
|
eventCallback()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockUpdateTools).toHaveBeenCalledWith(mockTools)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return unsubscribe function for cleanup', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useTools())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockListen).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Unmount should call the unsubscribe function
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(mockListen).toHaveBeenCalledWith(
|
||||||
|
SystemEvent.MCP_UPDATE,
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle getTools errors gracefully', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockGetTools.mockRejectedValue(new Error('Failed to get tools'))
|
||||||
|
|
||||||
|
renderHook(() => useTools())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Give enough time for the promise to be handled
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
// updateTools should not be called if getTools fails
|
||||||
|
expect(mockUpdateTools).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle event listener setup errors gracefully', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
mockListen.mockRejectedValue(new Error('Failed to set up listener'))
|
||||||
|
|
||||||
|
renderHook(() => useTools())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Give enough time for the promise to be handled
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initial getTools should still work
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockListen).toHaveBeenCalled()
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only set up effect once with empty dependency array', async () => {
|
||||||
|
const { useTools } = await import('../useTools')
|
||||||
|
|
||||||
|
const { rerender } = renderHook(() => useTools())
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockListen).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Rerender should not trigger additional calls
|
||||||
|
rerender()
|
||||||
|
expect(mockGetTools).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockListen).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { usePrompt } from './usePrompt'
|
import { usePrompt } from './usePrompt'
|
||||||
import { useModelProvider } from './useModelProvider'
|
import { useModelProvider } from './useModelProvider'
|
||||||
import { useThreads } from './useThreads'
|
import { useThreads } from './useThreads'
|
||||||
@ -19,10 +19,7 @@ import {
|
|||||||
import { CompletionMessagesBuilder } from '@/lib/messages'
|
import { CompletionMessagesBuilder } from '@/lib/messages'
|
||||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||||
import { useAssistant } from './useAssistant'
|
import { useAssistant } from './useAssistant'
|
||||||
import { getTools } from '@/services/mcp'
|
|
||||||
import { MCPTool } from '@/types/completion'
|
|
||||||
import { listen } from '@tauri-apps/api/event'
|
|
||||||
import { SystemEvent } from '@/types/events'
|
|
||||||
import { stopModel, startModel, stopAllModels } from '@/services/models'
|
import { stopModel, startModel, stopAllModels } from '@/services/models'
|
||||||
|
|
||||||
import { useToolApproval } from '@/hooks/useToolApproval'
|
import { useToolApproval } from '@/hooks/useToolApproval'
|
||||||
@ -40,7 +37,6 @@ export const useChat = () => {
|
|||||||
tools,
|
tools,
|
||||||
updateTokenSpeed,
|
updateTokenSpeed,
|
||||||
resetTokenSpeed,
|
resetTokenSpeed,
|
||||||
updateTools,
|
|
||||||
updateStreamingContent,
|
updateStreamingContent,
|
||||||
updateLoadingModel,
|
updateLoadingModel,
|
||||||
setAbortController,
|
setAbortController,
|
||||||
@ -77,22 +73,6 @@ export const useChat = () => {
|
|||||||
const selectedAssistant =
|
const selectedAssistant =
|
||||||
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function setTools() {
|
|
||||||
getTools().then((data: MCPTool[]) => {
|
|
||||||
updateTools(data)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setTools()
|
|
||||||
|
|
||||||
let unsubscribe = () => {}
|
|
||||||
listen(SystemEvent.MCP_UPDATE, setTools).then((unsub) => {
|
|
||||||
// Unsubscribe from the event when the component unmounts
|
|
||||||
unsubscribe = unsub
|
|
||||||
})
|
|
||||||
return unsubscribe
|
|
||||||
}, [updateTools])
|
|
||||||
|
|
||||||
const getCurrentThread = useCallback(async () => {
|
const getCurrentThread = useCallback(async () => {
|
||||||
let currentThread = retrieveThread()
|
let currentThread = retrieveThread()
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,10 @@ export type MCPServerConfig = {
|
|||||||
args: string[]
|
args: string[]
|
||||||
env: Record<string, string>
|
env: Record<string, string>
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
type?: 'stdio' | 'http' | 'sse'
|
||||||
|
url?: string
|
||||||
|
headers?: Record<string, string>
|
||||||
|
timeout?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define the structure of all MCP servers
|
// Define the structure of all MCP servers
|
||||||
|
|||||||
31
web-app/src/hooks/useTools.ts
Normal file
31
web-app/src/hooks/useTools.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { getTools } from '@/services/mcp'
|
||||||
|
import { MCPTool } from '@/types/completion'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
import { SystemEvent } from '@/types/events'
|
||||||
|
import { useAppState } from './useAppState'
|
||||||
|
|
||||||
|
export const useTools = () => {
|
||||||
|
const { updateTools } = useAppState()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function setTools() {
|
||||||
|
getTools().then((data: MCPTool[]) => {
|
||||||
|
updateTools(data)
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to fetch MCP tools:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setTools()
|
||||||
|
|
||||||
|
let unsubscribe = () => {}
|
||||||
|
listen(SystemEvent.MCP_UPDATE, setTools).then((unsub) => {
|
||||||
|
// Unsubscribe from the event when the component unmounts
|
||||||
|
unsubscribe = unsub
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Failed to set up MCP update listener:', error)
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
|
|||||||
import ChatInput from '@/containers/ChatInput'
|
import ChatInput from '@/containers/ChatInput'
|
||||||
import HeaderPage from '@/containers/HeaderPage'
|
import HeaderPage from '@/containers/HeaderPage'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { useTools } from '@/hooks/useTools'
|
||||||
|
|
||||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||||
import SetupScreen from '@/containers/SetupScreen'
|
import SetupScreen from '@/containers/SetupScreen'
|
||||||
@ -31,6 +32,7 @@ function Index() {
|
|||||||
const search = useSearch({ from: route.home as any })
|
const search = useSearch({ from: route.home as any })
|
||||||
const selectedModel = search.model
|
const selectedModel = search.model
|
||||||
const { setCurrentThreadId } = useThreads()
|
const { setCurrentThreadId } = useThreads()
|
||||||
|
useTools()
|
||||||
|
|
||||||
// Conditional to check if there are any valid providers
|
// Conditional to check if there are any valid providers
|
||||||
// required min 1 api_key or 1 model in llama.cpp
|
// required min 1 api_key or 1 model in llama.cpp
|
||||||
|
|||||||
@ -29,6 +29,58 @@ const maskSensitiveValue = (value: string) => {
|
|||||||
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4)
|
return value.slice(0, 4) + '*'.repeat(value.length - 8) + value.slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to mask sensitive URL parameters
|
||||||
|
const maskSensitiveUrl = (url: string) => {
|
||||||
|
if (!url) return url
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const params = urlObj.searchParams
|
||||||
|
|
||||||
|
// List of sensitive parameter names (case-insensitive)
|
||||||
|
const sensitiveParams = [
|
||||||
|
'api_key',
|
||||||
|
'apikey',
|
||||||
|
'key',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'password',
|
||||||
|
'pwd',
|
||||||
|
'auth',
|
||||||
|
'authorization',
|
||||||
|
'bearer',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'client_secret',
|
||||||
|
'private_key',
|
||||||
|
'signature',
|
||||||
|
'hash',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mask sensitive parameters
|
||||||
|
sensitiveParams.forEach((paramName) => {
|
||||||
|
// Check both exact match and case-insensitive match
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
if (key.toLowerCase() === paramName.toLowerCase()) {
|
||||||
|
params.set(key, maskSensitiveValue(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reconstruct URL with masked parameters
|
||||||
|
urlObj.search = params.toString()
|
||||||
|
return urlObj.toString()
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, just mask the entire query string after '?'
|
||||||
|
const queryIndex = url.indexOf('?')
|
||||||
|
if (queryIndex === -1) return url
|
||||||
|
|
||||||
|
const baseUrl = url.substring(0, queryIndex + 1)
|
||||||
|
const queryString = url.substring(queryIndex + 1)
|
||||||
|
return baseUrl + maskSensitiveValue(queryString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const Route = createFileRoute(route.settings.mcp_servers as any)({
|
export const Route = createFileRoute(route.settings.mcp_servers as any)({
|
||||||
component: MCPServers,
|
component: MCPServers,
|
||||||
@ -195,6 +247,7 @@ function MCPServers() {
|
|||||||
getConnectedServers().then(setConnectedServers)
|
getConnectedServers().then(setConnectedServers)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.log(error, 'error.mcp')
|
||||||
editServer(serverKey, {
|
editServer(serverKey, {
|
||||||
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
||||||
active: false,
|
active: false,
|
||||||
@ -326,22 +379,56 @@ function MCPServers() {
|
|||||||
}
|
}
|
||||||
descriptionOutside={
|
descriptionOutside={
|
||||||
<div className="text-sm text-main-view-fg/70">
|
<div className="text-sm text-main-view-fg/70">
|
||||||
<div>
|
<div className="mb-1">
|
||||||
{t('mcp-servers:command')}: {config.command}
|
Transport:{' '}
|
||||||
|
<span className="uppercase">
|
||||||
|
{config.type || 'stdio'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="my-1 break-all">
|
|
||||||
{t('mcp-servers:args')}: {config?.args?.join(', ')}
|
{config.type === 'stdio' || !config.type ? (
|
||||||
</div>
|
<>
|
||||||
{config.env && Object.keys(config.env).length > 0 && (
|
<div>
|
||||||
<div className="break-all">
|
{t('mcp-servers:command')}: {config.command}
|
||||||
{t('mcp-servers:env')}:{' '}
|
</div>
|
||||||
{Object.entries(config.env)
|
<div className="my-1 break-all">
|
||||||
.map(
|
{t('mcp-servers:args')}:{' '}
|
||||||
([key, value]) =>
|
{config?.args?.join(', ')}
|
||||||
`${key}=${maskSensitiveValue(value)}`
|
</div>
|
||||||
)
|
{config.env &&
|
||||||
.join(', ')}
|
Object.keys(config.env).length > 0 && (
|
||||||
</div>
|
<div className="break-all">
|
||||||
|
{t('mcp-servers:env')}:{' '}
|
||||||
|
{Object.entries(config.env)
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${key}=${maskSensitiveValue(value)}`
|
||||||
|
)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="break-all">
|
||||||
|
URL: {maskSensitiveUrl(config.url || '')}
|
||||||
|
</div>
|
||||||
|
{config.headers &&
|
||||||
|
Object.keys(config.headers).length > 0 && (
|
||||||
|
<div className="my-1 break-all">
|
||||||
|
Headers:{' '}
|
||||||
|
{Object.entries(config.headers)
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${key}=${maskSensitiveValue(value)}`
|
||||||
|
)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{config.timeout && (
|
||||||
|
<div>Timeout: {config.timeout}s</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { ContentType, ThreadMessage } from '@janhq/core'
|
|||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { useChat } from '@/hooks/useChat'
|
import { useChat } from '@/hooks/useChat'
|
||||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
|
import { useTools } from '@/hooks/useTools'
|
||||||
|
|
||||||
// as route.threadsDetail
|
// as route.threadsDetail
|
||||||
export const Route = createFileRoute('/threads/$threadId')({
|
export const Route = createFileRoute('/threads/$threadId')({
|
||||||
@ -43,6 +44,7 @@ function ThreadDetail() {
|
|||||||
const { appMainViewBgColor, chatWidth } = useAppearance()
|
const { appMainViewBgColor, chatWidth } = useAppearance()
|
||||||
const { sendMessage } = useChat()
|
const { sendMessage } = useChat()
|
||||||
const isSmallScreen = useSmallScreen()
|
const isSmallScreen = useSmallScreen()
|
||||||
|
useTools()
|
||||||
|
|
||||||
const { messages } = useMessages(
|
const { messages } = useMessages(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
|
|||||||
@ -6,4 +6,5 @@ export type MCPTool = {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
inputSchema: Record<string, unknown>
|
inputSchema: Record<string, unknown>
|
||||||
|
server: string
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user