"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"; 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(() => { let cancelled = false; // "Load data on mount" pattern: fetch* callbacks call setState internally // after async resolution. The cancelled flag prevents setState after unmount. // The react-hooks/set-state-in-effect rule flags this pattern conservatively // (it cannot distinguish sync vs async setState), but this is the canonical // way to load remote data on mount until we migrate to use()/Suspense. /* eslint-disable react-hooks/set-state-in-effect */ Promise.all([ fetchConnections(), fetchApiKeys(), fetchCloudSettings(), fetchDynamicModels(), ]).finally(() => { if (!cancelled) setLoading(false); }); /* eslint-enable react-hooks/set-state-in-effect */ return () => { cancelled = true; }; }, [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 "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() )}
); }