diff --git a/src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx b/src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx new file mode 100644 index 0000000000..78f162d9ed --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx @@ -0,0 +1,14 @@ +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { notFound } from "next/navigation"; +import ToolDetailClient from "../../cli-code/components/ToolDetailClient"; + +export default async function CliAgentsDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const tool = CLI_TOOLS[id]; + if (!tool || tool.category !== "agent") notFound(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx b/src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx new file mode 100644 index 0000000000..648758ef76 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx @@ -0,0 +1,14 @@ +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { notFound } from "next/navigation"; +import ToolDetailClient from "../components/ToolDetailClient"; + +export default async function CliCodeDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const tool = CLI_TOOLS[id]; + if (!tool || tool.category !== "code") notFound(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx index 08c130c87a..7dfb0781c9 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx @@ -8,8 +8,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; export default function AntigravityToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, activeProviders, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx index 6178009432..8d85109303 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx @@ -14,8 +14,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function ClaudeToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, activeProviders, modelMappings, onModelMappingChange, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/cli-tools/components/CliStatusBadge.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge.tsx diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx index 0333831baf..c47ce772f2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx @@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function ClineToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, hasActiveProviders, apiKeys, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CliproxyapiToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/CliproxyapiToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx index 5a154adcdd..68733f13b2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CliproxyapiToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx @@ -23,7 +23,7 @@ interface UpdateInfo { updateAvailable: boolean; } -export default function CliproxyapiToolCard({ isExpanded, onToggle }) { +export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) { const [toolState, setToolState] = useState(null); const [updateInfo, setUpdateInfo] = useState(null); const [loading, setLoading] = useState(null); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx index bb1a645235..ab4ad23c63 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx @@ -10,8 +10,8 @@ import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl"; export default function CodexToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, activeProviders, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx index f4740005bb..17128b165b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx @@ -15,8 +15,8 @@ import { useTranslations } from "next-intl"; */ export default function CopilotToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, activeProviders = [], diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx index e0119498ba..b178fa0892 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx @@ -24,8 +24,8 @@ interface CustomCliMappingRow { export default function CustomCliCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, availableModels = [], diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx index 95d3a4d9a9..cd74884a0c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx @@ -13,8 +13,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; export default function DefaultToolCard({ toolId, tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, activeProviders = [], diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx index 5fdd12b268..b08b89db24 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx @@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function DroidToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, hasActiveProviders, apiKeys, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/HermesAgentToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx index 5731ac27fa..dcdee025c2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesAgentToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx @@ -25,8 +25,8 @@ const HERMES_ROLES: Role[] = [ export default function HermesAgentToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, apiKeys, activeProviders = [], diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx index e99243968b..9b597e482c 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx @@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function KiloToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, hasActiveProviders, apiKeys, diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx similarity index 99% rename from src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx index fd365e9e89..ff023a2f31 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx @@ -10,8 +10,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; export default function OpenClawToolCard({ tool, - isExpanded, - onToggle, + isExpanded = false, + onToggle = () => {}, baseUrl, hasActiveProviders, apiKeys, diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx new file mode 100644 index 0000000000..383d8d9f9f --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { PROVIDER_ID_TO_ALIAS, getModelsByProviderId } from "@/shared/constants/models"; +import { + AntigravityToolCard, + ClaudeToolCard, + ClineToolCard, + CodexToolCard, + CopilotToolCard, + CustomCliCard, + DefaultToolCard, + DroidToolCard, + HermesAgentToolCard, + KiloToolCard, + OpenClawToolCard, +} from "./index"; +import CliproxyapiToolCard from "./CliproxyapiToolCard"; + +export interface ToolDetailClientProps { + toolId: string; + category: "code" | "agent"; +} + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function ToolDetailClient({ toolId, category }: ToolDetailClientProps) { + const tool = CLI_TOOLS[toolId]; + + const [connections, setConnections] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [cloudEnabled, setCloudEnabled] = useState(false); + const [dynamicModels, setDynamicModels] = useState([]); + const [modelMappings, setModelMappings] = useState>({}); + const [loading, setLoading] = useState(true); + + const fetchConnections = useCallback(async () => { + try { + const res = await fetch("/api/providers"); + if (res.ok) { + const data = await res.json(); + setConnections(data.connections || []); + } + } catch (error) { + console.log("Error fetching connections:", error); + } + }, []); + + const fetchApiKeys = useCallback(async () => { + try { + const res = await fetch("/api/cli-tools/keys"); + if (res.ok) { + const data = await res.json(); + setApiKeys(data.keys || []); + } + } catch (error) { + console.log("Error fetching API keys:", error); + } + }, []); + + const fetchCloudSettings = useCallback(async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setCloudEnabled(data.cloudEnabled || false); + } + } catch (error) { + console.log("Error loading cloud settings:", error); + } + }, []); + + const fetchDynamicModels = useCallback(async () => { + try { + const res = await fetch("/v1/models"); + if (res.ok) { + const data = await res.json(); + setDynamicModels(data?.data || []); + } + } catch (error) { + console.log("Error fetching dynamic models:", error); + } + }, []); + + useEffect(() => { + Promise.all([ + fetchConnections(), + fetchApiKeys(), + fetchCloudSettings(), + fetchDynamicModels(), + ]).finally(() => setLoading(false)); + }, [fetchConnections, fetchApiKeys, fetchCloudSettings, fetchDynamicModels]); + + const getActiveProviders = useCallback(() => { + return connections.filter((c) => c.isActive !== false); + }, [connections]); + + const getAllAvailableModels = useCallback(() => { + const activeProviders = getActiveProviders(); + const models: any[] = []; + const seenModels = new Set(); + + activeProviders.forEach((conn) => { + const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; + const providerModels = getModelsByProviderId(conn.provider); + providerModels.forEach((m: any) => { + const modelValue = `${alias}/${m.id}`; + if (!seenModels.has(modelValue)) { + seenModels.add(modelValue); + models.push({ + value: modelValue, + label: `${alias}/${m.id}`, + provider: conn.provider, + alias, + connectionName: conn.name, + modelId: m.id, + }); + } + }); + }); + + const activeAliases = new Set( + activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider) + ); + const activeProviderIds = new Set(activeProviders.map((c) => c.provider)); + dynamicModels.forEach((dm) => { + const rawId = dm?.id ?? dm; + const modelId = typeof rawId === "string" ? rawId : ""; + if (!modelId || seenModels.has(modelId)) return; + const slashIdx = modelId.indexOf("/"); + if (slashIdx === -1) return; + const alias = modelId.substring(0, slashIdx); + const bareModel = modelId.substring(slashIdx + 1); + if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return; + seenModels.add(modelId); + models.push({ + value: modelId, + label: modelId, + provider: alias, + alias, + connectionName: "", + modelId: bareModel, + }); + }); + + return models; + }, [getActiveProviders, dynamicModels]); + + const handleModelMappingChange = useCallback((alias: string, targetModel: string) => { + setModelMappings((prev) => { + if (prev[alias] === targetModel) return prev; + return { ...prev, [alias]: targetModel }; + }); + }, []); + + const getBaseUrl = useCallback(() => { + if (cloudEnabled && CLOUD_URL) return CLOUD_URL; + if (typeof window !== "undefined") return window.location.origin; + return ""; + }, [cloudEnabled]); + + if (!tool) return null; + + const activeProviders = getActiveProviders(); + const availableModels = getAllAvailableModels(); + const hasActiveProviders = availableModels.length > 0; + + const backCategory = category === "code" ? "/dashboard/cli-code" : "/dashboard/cli-agents"; + + // Common props passed to every specialized card. + // isExpanded is always true in the detail page (D23). + const cardProps: any = { + tool, + isExpanded: true, + onToggle: () => {}, + baseUrl: getBaseUrl(), + apiKeys, + batchStatus: null, + lastConfiguredAt: null, + activeProviders, + hasActiveProviders, + cloudEnabled, + availableModels, + }; + + const renderCard = () => { + switch (toolId) { + case "claude": + return ( + + ); + case "codex": + return ; + case "droid": + return ; + case "openclaw": + return ; + case "cline": + return ; + case "kilo": + return ; + case "copilot": + return ; + case "hermes-agent": + return ; + case "antigravity": + return ; + case "cliproxyapi": + return {}} />; + case "custom": + return ; + default: + if (tool.configType === "mitm") { + return ; + } + return ; + } + }; + + return ( +
+ {/* Back navigation */} +
+ + arrow_back + {category === "code" ? "CLI Code" : "CLI Agents"} + + / + {tool.name} +
+ + {/* Tool header */} +
+ {tool.vendor && ( + + {tool.vendor} + + )} + + {category} + + {tool.baseUrlSupport && tool.baseUrlSupport !== "none" && ( + + link + {tool.baseUrlSupport === "full" ? "Full base URL" : "Partial base URL"} + + )} +
+ + {/* Specialized card — always expanded */} + {loading ? ( +
+
+
+ ) : ( + renderCard() + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts b/src/app/(dashboard)/dashboard/cli-code/components/customCliConfig.ts similarity index 100% rename from src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts rename to src/app/(dashboard)/dashboard/cli-code/components/customCliConfig.ts diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.tsx b/src/app/(dashboard)/dashboard/cli-code/components/index.tsx similarity index 100% rename from src/app/(dashboard)/dashboard/cli-tools/components/index.tsx rename to src/app/(dashboard)/dashboard/cli-code/components/index.tsx diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx deleted file mode 100644 index fc9a6c6586..0000000000 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ /dev/null @@ -1,513 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { Card, CardSkeleton, SegmentedControl } from "@/shared/components"; -import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { - PROVIDER_MODELS, - getModelsByProviderId, - PROVIDER_ID_TO_ALIAS, -} from "@/shared/constants/models"; -import { - ClaudeToolCard, - CodexToolCard, - DroidToolCard, - OpenClawToolCard, - ClineToolCard, - KiloToolCard, - DefaultToolCard, - AntigravityToolCard, - CopilotToolCard, - CustomCliCard, - HermesAgentToolCard, -} from "./components"; -import { useTranslations } from "next-intl"; -import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; - -const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; -const AUTO_CONFIGURED_TOOL_IDS = new Set([ - "claude", - "codex", - "droid", - "openclaw", - "cline", - "kilo", - "copilot", - "hermes-agent", -]); -const GUIDED_TOOL_IDS = new Set([ - "cursor", - "windsurf", - "continue", - "opencode", - "hermes", - "amp", - "qwen", -]); -const MITM_TOOL_IDS = new Set(["antigravity", "kiro"]); -const CUSTOM_TOOL_IDS = new Set(["custom"]); - -export default function CLIToolsPageClient({ machineId: _machineId }) { - const t = useTranslations("cliTools"); - const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(true); - const [expandedTool, setExpandedTool] = useState(null); - const [modelMappings, setModelMappings] = useState({}); - const [cloudEnabled, setCloudEnabled] = useState(false); - const [apiKeys, setApiKeys] = useState([]); - const [toolStatuses, setToolStatuses] = useState({}); - const [statusesLoaded, setStatusesLoaded] = useState(false); - const [dynamicModels, setDynamicModels] = useState([]); - const [activeCategory, setActiveCategory] = useState("auto"); - const translateOrFallback = useCallback( - (key, fallback, values = undefined) => { - try { - const translated = t(key, values); - return translated === key || translated === `cliTools.${key}` ? fallback : translated; - } catch { - return fallback; - } - }, - [t] - ); - - useEffect(() => { - fetchConnections(); - loadCloudSettings(); - fetchApiKeys(); - fetchToolStatuses(); - fetchDynamicModels(); - }, []); - - const loadCloudSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (res.ok) { - const data = await res.json(); - setCloudEnabled(data.cloudEnabled || false); - } - } catch (error) { - console.log("Error loading cloud settings:", error); - } - }; - - const fetchApiKeys = async () => { - try { - const res = await fetch("/api/cli-tools/keys"); - if (res.ok) { - const data = await res.json(); - setApiKeys(data.keys || []); - } - } catch (error) { - console.log("Error fetching API keys:", error); - } - }; - - const fetchToolStatuses = async () => { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s client timeout - const res = await fetch("/api/cli-tools/status", { signal: controller.signal }); - clearTimeout(timeoutId); - if (res.ok) { - const data = await res.json(); - setToolStatuses(data || {}); - } - } catch (error) { - // Timeout or network error — proceed without statuses - console.log("CLI tool status check timed out or failed:", error); - } finally { - setStatusesLoaded(true); - } - }; - - const fetchConnections = async () => { - try { - const res = await fetch("/api/providers"); - const data = await res.json(); - if (res.ok) { - setConnections(data.connections || []); - } - } catch (error) { - console.log("Error fetching connections:", error); - } finally { - setLoading(false); - } - }; - - const fetchDynamicModels = async () => { - try { - const res = await fetch("/v1/models"); - if (res.ok) { - const data = await res.json(); - setDynamicModels(data?.data || []); - } - } catch (error) { - console.log("Error fetching dynamic models:", error); - } - }; - - const getActiveProviders = () => { - return connections.filter((c) => c.isActive !== false); - }; - - const getAllAvailableModels = () => { - const activeProviders = getActiveProviders(); - const models = []; - const seenModels = new Set(); - - // First: add static models from the constants - activeProviders.forEach((conn) => { - const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; - const providerModels = getModelsByProviderId(conn.provider); - providerModels.forEach((m) => { - const modelValue = `${alias}/${m.id}`; - if (!seenModels.has(modelValue)) { - seenModels.add(modelValue); - models.push({ - value: modelValue, - label: `${alias}/${m.id}`, - provider: conn.provider, - alias: alias, - connectionName: conn.name, - modelId: m.id, - }); - } - }); - }); - - // Second: add dynamic models from /v1/models (fills gaps for Kiro, OpenCode, custom providers) - const activeProviderIds = new Set(activeProviders.map((c) => c.provider)); - const activeAliases = new Set( - activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider) - ); - dynamicModels.forEach((dm) => { - const rawId = dm?.id ?? dm; - const modelId = typeof rawId === "string" ? rawId : ""; - if (!modelId || seenModels.has(modelId)) return; - // Parse alias/model format - const slashIdx = modelId.indexOf("/"); - if (slashIdx === -1) return; - const alias = modelId.substring(0, slashIdx); - const bareModel = modelId.substring(slashIdx + 1); - if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return; - seenModels.add(modelId); - models.push({ - value: modelId, - label: modelId, - provider: alias, - alias: alias, - connectionName: "", - modelId: bareModel, - }); - }); - - return models; - }; - - const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => { - setModelMappings((prev) => { - // Prevent unnecessary updates if value hasn't changed - if (prev[toolId]?.[modelAlias] === targetModel) { - return prev; - } - return { - ...prev, - [toolId]: { - ...prev[toolId], - [modelAlias]: targetModel, - }, - }; - }); - }, []); - - const getBaseUrl = () => { - if (cloudEnabled && CLOUD_URL) { - return CLOUD_URL; - } - // Use window.location.origin directly — works correctly in Docker/reverse-proxy - // Per @alpgul feedback: don't use baseUrl prop (has port duplication issues) - if (typeof window !== "undefined") { - return window.location.origin; - } - return DEFAULT_DISPLAY_BASE_URL; - }; - - if (loading || !statusesLoaded) { - return ( -
-
- - - -
-
- ); - } - - const availableModels = getAllAvailableModels(); - const hasActiveProviders = availableModels.length > 0; - const toolEntries = Object.entries(CLI_TOOLS).filter(([toolId]) => { - if (activeCategory === "all") return true; - if (activeCategory === "auto") return AUTO_CONFIGURED_TOOL_IDS.has(toolId); - if (activeCategory === "guided") return GUIDED_TOOL_IDS.has(toolId); - if (activeCategory === "mitm") return MITM_TOOL_IDS.has(toolId); - if (activeCategory === "custom") return CUSTOM_TOOL_IDS.has(toolId); - return true; - }); - - const renderToolCard = (toolId, tool) => { - const commonProps = { - tool, - isExpanded: expandedTool === toolId, - onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId), - baseUrl: getBaseUrl(), - apiKeys, - batchStatus: toolStatuses[toolId] || null, - lastConfiguredAt: toolStatuses[toolId]?.lastConfiguredAt || null, - }; - - switch (toolId) { - case "claude": - return ( - - handleModelMappingChange(toolId, alias, target) - } - hasActiveProviders={hasActiveProviders} - cloudEnabled={cloudEnabled} - /> - ); - case "codex": - return ( - - ); - case "droid": - return ( - - ); - case "openclaw": - return ( - - ); - case "antigravity": - return ( - - ); - case "cline": - return ( - - ); - case "kilo": - return ( - - ); - case "copilot": - return ( - - ); - case "hermes-agent": - return ( - - ); - case "custom": - return ( - - ); - default: - // #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls) - if (tool.configType === "mitm") { - return ( - - ); - } - return ( - - ); - } - }; - - const getToolDocsHref = (toolId, tool) => { - if (typeof tool.docsUrl === "string" && tool.docsUrl.trim()) { - return tool.docsUrl.trim(); - } - return `/docs?section=cli-tools&tool=${toolId}`; - }; - - const getToolUseCase = (toolId, tool) => { - const fallbackDescription = translateOrFallback(`toolDescriptions.${toolId}`, tool.description); - return translateOrFallback(`toolUseCases.${toolId}`, fallbackDescription); - }; - - return ( -
- -
-
- tips_and_updates -
-
-

{t("howItWorks")}

-
-
- {t("installationGuide")} -
-
- {t("configureEndpoint")} -
-
- {t("testConnection")} -
-
-
-
-
- - -
-
-
-

{t("toolCategories")}

-

{t("toolCategoriesDesc")}

-
- - {t("visibleToolsCount", { count: toolEntries.length })} - -
- -
-
- - {!hasActiveProviders && ( - -
- warning -
-

- {t("noActiveProviders")} -

-

{t("noActiveProvidersDesc")}

-
-
-
- )} - -
- {toolEntries.map(([toolId, tool]) => { - const docsHref = getToolDocsHref(toolId, tool); - const isExternalDocs = /^https?:\/\//i.test(docsHref); - return ( -
- {renderToolCard(toolId, tool)} -
-
-
-

- {t("whenToUseLabel")} -

-

- {getToolUseCase(toolId, tool)} -

-
- - - {t("openToolDocs")} - -
-
-
- ); - })} -
-
- ); -} diff --git a/src/app/(dashboard)/dashboard/cli-tools/page.tsx b/src/app/(dashboard)/dashboard/cli-tools/page.tsx deleted file mode 100644 index 24f6030ae8..0000000000 --- a/src/app/(dashboard)/dashboard/cli-tools/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { getMachineId } from "@/shared/utils/machine"; -import CLIToolsPageClient from "./CLIToolsPageClient"; - -export default async function CLIToolsPage() { - const machineId = await getMachineId(); - return ; -} diff --git a/tests/unit/ui/ToolDetailClient.test.tsx b/tests/unit/ui/ToolDetailClient.test.tsx new file mode 100644 index 0000000000..738afc19a2 --- /dev/null +++ b/tests/unit/ui/ToolDetailClient.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +// Stub fetch globally +const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }), +}); +vi.stubGlobal("fetch", mockFetch); + +// Stub next/navigation +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NOT_FOUND"); + }, +})); + +// Stub CLI_TOOLS catalog +vi.mock("@/shared/constants/cliTools", () => ({ + CLI_TOOLS: { + claude: { + id: "claude", + name: "Claude Code", + icon: "terminal", + color: "#D97757", + category: "code", + configType: "env", + vendor: "Anthropic", + baseUrlSupport: "full", + defaultModels: [], + }, + codex: { + id: "codex", + name: "Codex", + icon: "terminal", + color: "#000", + category: "code", + configType: "custom", + vendor: "OpenAI", + baseUrlSupport: "full", + defaultModels: [], + }, + custom: { + id: "custom", + name: "Custom CLI", + icon: "terminal", + color: "#888", + category: "code", + configType: "custom-builder", + vendor: undefined, + baseUrlSupport: "full", + defaultModels: [], + }, + "hermes-agent": { + id: "hermes-agent", + name: "Hermes Agent", + icon: "terminal", + color: "#5865f2", + category: "agent", + configType: "custom", + vendor: "HermesAI", + baseUrlSupport: "full", + defaultModels: [], + }, + forge: { + id: "forge", + name: "Forge", + icon: "terminal", + color: "#888", + category: "code", + configType: "custom", + vendor: undefined, + baseUrlSupport: "partial", + defaultModels: [], + }, + }, +})); + +// Stub model constants +vi.mock("@/shared/constants/models", () => ({ + PROVIDER_ID_TO_ALIAS: {}, + getModelsByProviderId: () => [], +})); + +// Stub specialized cards — render a testid so we can identify which was rendered +vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({ + ClaudeToolCard: () =>
, + CodexToolCard: () =>
, + DroidToolCard: () =>
, + OpenClawToolCard: () =>
, + ClineToolCard: () =>
, + KiloToolCard: () =>
, + DefaultToolCard: ({ toolId }: { toolId: string }) => ( +
+ ), + AntigravityToolCard: () =>
, + CopilotToolCard: () =>
, + CustomCliCard: () =>
, + HermesAgentToolCard: () =>
, +})); + +vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({ + default: () =>
, +})); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { default: ToolDetailClient } = await import( + "@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient" +); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const containers: HTMLElement[] = []; + +function renderDetail(toolId: string, category: "code" | "agent"): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + const root = createRoot(container); + act(() => { + root.render(); + }); + return container; +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockFetch.mockClear(); +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ToolDetailClient", () => { + it("renders ClaudeToolCard for toolId=claude", async () => { + const container = renderDetail("claude", "code"); + // Wait for async state resolution + await act(async () => {}); + expect(container.querySelector("[data-testid='ClaudeToolCard']")).not.toBeNull(); + }); + + it("renders CodexToolCard for toolId=codex", async () => { + const container = renderDetail("codex", "code"); + await act(async () => {}); + expect(container.querySelector("[data-testid='CodexToolCard']")).not.toBeNull(); + }); + + it("renders CustomCliCard for toolId=custom", async () => { + const container = renderDetail("custom", "code"); + await act(async () => {}); + expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull(); + }); + + it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => { + const container = renderDetail("forge", "code"); + await act(async () => {}); + const card = container.querySelector("[data-testid='DefaultToolCard']"); + expect(card).not.toBeNull(); + expect(card!.getAttribute("data-toolid")).toBe("forge"); + }); + + it("renders nothing (null) for completely unknown toolId", async () => { + const container = renderDetail("totally-unknown-xyz", "code"); + await act(async () => {}); + // CLI_TOOLS["totally-unknown-xyz"] is undefined → returns null → empty container + expect(container.textContent).toBe(""); + }); +}); diff --git a/tests/unit/ui/cli-agents-detail-page.test.tsx b/tests/unit/ui/cli-agents-detail-page.test.tsx new file mode 100644 index 0000000000..82e20b0aaf --- /dev/null +++ b/tests/unit/ui/cli-agents-detail-page.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NOT_FOUND"); + }, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }), +})); + +vi.mock("@/shared/constants/models", () => ({ + PROVIDER_ID_TO_ALIAS: {}, + getModelsByProviderId: () => [], +})); + +// Stub ToolDetailClient — renders a testid with props +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({ + default: ({ toolId, category }: { toolId: string; category: string }) => ( +
+ ), +})); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { default: CliAgentsDetailPage } = await import( + "@/app/(dashboard)/dashboard/cli-agents/[id]/page" +); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const containers: HTMLElement[] = []; + +async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> { + let notFoundThrown = false; + let jsx: React.ReactNode | null = null; + + try { + jsx = await CliAgentsDetailPage({ params: Promise.resolve({ id }) }); + } catch (err: any) { + if (err?.message === "NOT_FOUND") { + notFoundThrown = true; + } else { + throw err; + } + } + + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + if (!notFoundThrown && jsx) { + const root = createRoot(container); + act(() => { + root.render(jsx as React.ReactElement); + }); + } + + return { container, notFound: notFoundThrown }; +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("CliAgentsDetailPage", () => { + it("renders ToolDetailClient for /dashboard/cli-agents/hermes-agent (category:agent)", async () => { + const { container, notFound } = await renderPage("hermes-agent"); + expect(notFound).toBe(false); + const el = container.querySelector("[data-testid='ToolDetailClient']"); + expect(el).not.toBeNull(); + expect(el!.getAttribute("data-toolid")).toBe("hermes-agent"); + expect(el!.getAttribute("data-category")).toBe("agent"); + }); + + it("returns 404 for /dashboard/cli-agents/claude (category:code — cross-category)", async () => { + const { notFound } = await renderPage("claude"); + expect(notFound).toBe(true); + }); + + it("returns 404 for /dashboard/cli-agents/invalid-id (unknown tool)", async () => { + const { notFound } = await renderPage("invalid-id-xyz"); + expect(notFound).toBe(true); + }); +}); diff --git a/tests/unit/ui/cli-code-detail-page.test.tsx b/tests/unit/ui/cli-code-detail-page.test.tsx new file mode 100644 index 0000000000..da28390033 --- /dev/null +++ b/tests/unit/ui/cli-code-detail-page.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +let notFoundCalled = false; + +vi.mock("next/navigation", () => ({ + notFound: () => { + notFoundCalled = true; + throw new Error("NOT_FOUND"); + }, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }), +})); + +vi.mock("@/shared/constants/models", () => ({ + PROVIDER_ID_TO_ALIAS: {}, + getModelsByProviderId: () => [], +})); + +// Stub ToolDetailClient — just renders a testid with the received props +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient", () => ({ + default: ({ toolId, category }: { toolId: string; category: string }) => ( +
+ ), +})); + +// ── Import after mocks ──────────────────────────────────────────────────────── + +const { default: CliCodeDetailPage } = await import( + "@/app/(dashboard)/dashboard/cli-code/[id]/page" +); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const containers: HTMLElement[] = []; + +async function renderPage(id: string): Promise<{ container: HTMLElement; notFound: boolean }> { + let notFoundThrown = false; + let jsx: React.ReactNode | null = null; + + try { + jsx = await CliCodeDetailPage({ params: Promise.resolve({ id }) }); + } catch (err: any) { + if (err?.message === "NOT_FOUND") { + notFoundThrown = true; + } else { + throw err; + } + } + + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + + if (!notFoundThrown && jsx) { + const root = createRoot(container); + act(() => { + root.render(jsx as React.ReactElement); + }); + } + + return { container, notFound: notFoundThrown }; +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + notFoundCalled = false; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("CliCodeDetailPage", () => { + it("renders ToolDetailClient for /dashboard/cli-code/claude (category:code)", async () => { + const { container, notFound } = await renderPage("claude"); + expect(notFound).toBe(false); + const el = container.querySelector("[data-testid='ToolDetailClient']"); + expect(el).not.toBeNull(); + expect(el!.getAttribute("data-toolid")).toBe("claude"); + expect(el!.getAttribute("data-category")).toBe("code"); + }); + + it("returns 404 for /dashboard/cli-code/hermes-agent (category:agent — cross-category)", async () => { + const { notFound } = await renderPage("hermes-agent"); + expect(notFound).toBe(true); + }); + + it("returns 404 for /dashboard/cli-code/invalid-id (unknown tool)", async () => { + const { notFound } = await renderPage("invalid-id-xyz"); + expect(notFound).toBe(true); + }); +});