mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
refactor(dashboard,cli): move cards to cli-code/components + add detail pages /cli-code/[id] + /cli-agents/[id] + ToolDetailClient orchestrator (plan 14 F8)
- git mv cli-tools/components → cli-code/components (history preserved, 15 renames)
- Add isExpanded=false + onToggle=()=>{} defaults to all 12 specialized cards
- Create cli-code/[id]/page.tsx: server component, guards category=code
- Create cli-agents/[id]/page.tsx: server component, guards category=agent
- Create cli-code/components/ToolDetailClient.tsx: orchestrator with isExpanded:true always (D23), header with back-link + vendor/category/baseUrl badges
- Delete cli-tools/CLIToolsPageClient.tsx + cli-tools/page.tsx (accordion-based orchestrator replaced)
- Tests: ToolDetailClient smoke (5), cli-code detail page (3), cli-agents detail page (3) — 11/11 passing
This commit is contained in:
14
src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx
Normal file
14
src/app/(dashboard)/dashboard/cli-agents/[id]/page.tsx
Normal file
@@ -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 <ToolDetailClient toolId={id} category="agent" />;
|
||||||
|
}
|
||||||
14
src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx
Normal file
14
src/app/(dashboard)/dashboard/cli-code/[id]/page.tsx
Normal file
@@ -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 <ToolDetailClient toolId={id} category="code" />;
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
|||||||
|
|
||||||
export default function AntigravityToolCard({
|
export default function AntigravityToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
activeProviders,
|
activeProviders,
|
||||||
@@ -14,8 +14,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|||||||
|
|
||||||
export default function ClaudeToolCard({
|
export default function ClaudeToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
activeProviders,
|
activeProviders,
|
||||||
modelMappings,
|
modelMappings,
|
||||||
onModelMappingChange,
|
onModelMappingChange,
|
||||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|||||||
|
|
||||||
export default function ClineToolCard({
|
export default function ClineToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasActiveProviders,
|
hasActiveProviders,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
@@ -23,7 +23,7 @@ interface UpdateInfo {
|
|||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CliproxyapiToolCard({ isExpanded, onToggle }) {
|
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
@@ -10,8 +10,8 @@ import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl";
|
|||||||
|
|
||||||
export default function CodexToolCard({
|
export default function CodexToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
activeProviders,
|
activeProviders,
|
||||||
@@ -15,8 +15,8 @@ import { useTranslations } from "next-intl";
|
|||||||
*/
|
*/
|
||||||
export default function CopilotToolCard({
|
export default function CopilotToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
activeProviders = [],
|
activeProviders = [],
|
||||||
@@ -24,8 +24,8 @@ interface CustomCliMappingRow {
|
|||||||
|
|
||||||
export default function CustomCliCard({
|
export default function CustomCliCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
availableModels = [],
|
availableModels = [],
|
||||||
@@ -13,8 +13,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
|||||||
export default function DefaultToolCard({
|
export default function DefaultToolCard({
|
||||||
toolId,
|
toolId,
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
activeProviders = [],
|
activeProviders = [],
|
||||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|||||||
|
|
||||||
export default function DroidToolCard({
|
export default function DroidToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasActiveProviders,
|
hasActiveProviders,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
@@ -25,8 +25,8 @@ const HERMES_ROLES: Role[] = [
|
|||||||
|
|
||||||
export default function HermesAgentToolCard({
|
export default function HermesAgentToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
activeProviders = [],
|
activeProviders = [],
|
||||||
@@ -11,8 +11,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|||||||
|
|
||||||
export default function KiloToolCard({
|
export default function KiloToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasActiveProviders,
|
hasActiveProviders,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
@@ -10,8 +10,8 @@ const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
|||||||
|
|
||||||
export default function OpenClawToolCard({
|
export default function OpenClawToolCard({
|
||||||
tool,
|
tool,
|
||||||
isExpanded,
|
isExpanded = false,
|
||||||
onToggle,
|
onToggle = () => {},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
hasActiveProviders,
|
hasActiveProviders,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
@@ -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<any[]>([]);
|
||||||
|
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
||||||
|
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||||
|
const [dynamicModels, setDynamicModels] = useState<any[]>([]);
|
||||||
|
const [modelMappings, setModelMappings] = useState<Record<string, string>>({});
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ClaudeToolCard
|
||||||
|
{...cardProps}
|
||||||
|
modelMappings={modelMappings}
|
||||||
|
onModelMappingChange={handleModelMappingChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "codex":
|
||||||
|
return <CodexToolCard {...cardProps} />;
|
||||||
|
case "droid":
|
||||||
|
return <DroidToolCard {...cardProps} />;
|
||||||
|
case "openclaw":
|
||||||
|
return <OpenClawToolCard {...cardProps} />;
|
||||||
|
case "cline":
|
||||||
|
return <ClineToolCard {...cardProps} />;
|
||||||
|
case "kilo":
|
||||||
|
return <KiloToolCard {...cardProps} />;
|
||||||
|
case "copilot":
|
||||||
|
return <CopilotToolCard {...cardProps} />;
|
||||||
|
case "hermes-agent":
|
||||||
|
return <HermesAgentToolCard {...cardProps} />;
|
||||||
|
case "antigravity":
|
||||||
|
return <AntigravityToolCard {...cardProps} />;
|
||||||
|
case "cliproxyapi":
|
||||||
|
return <CliproxyapiToolCard isExpanded={true} onToggle={() => {}} />;
|
||||||
|
case "custom":
|
||||||
|
return <CustomCliCard {...cardProps} />;
|
||||||
|
default:
|
||||||
|
if (tool.configType === "mitm") {
|
||||||
|
return <AntigravityToolCard {...cardProps} />;
|
||||||
|
}
|
||||||
|
return <DefaultToolCard toolId={toolId} {...cardProps} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={backCategory}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-text-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||||
|
{category === "code" ? "CLI Code" : "CLI Agents"}
|
||||||
|
</Link>
|
||||||
|
<span className="text-text-muted">/</span>
|
||||||
|
<span className="text-sm font-medium">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool header */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{tool.vendor && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-surface border border-border text-text-muted">
|
||||||
|
{tool.vendor}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
{tool.baseUrlSupport && tool.baseUrlSupport !== "none" && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400">
|
||||||
|
<span className="material-symbols-outlined text-[12px]">link</span>
|
||||||
|
{tool.baseUrlSupport === "full" ? "Full base URL" : "Partial base URL"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Specialized card — always expanded */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="h-24 rounded-xl bg-surface animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderCard()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
<CardSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<ClaudeToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
modelMappings={modelMappings[toolId] || {}}
|
|
||||||
onModelMappingChange={(alias, target) =>
|
|
||||||
handleModelMappingChange(toolId, alias, target)
|
|
||||||
}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "codex":
|
|
||||||
return (
|
|
||||||
<CodexToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "droid":
|
|
||||||
return (
|
|
||||||
<DroidToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "openclaw":
|
|
||||||
return (
|
|
||||||
<OpenClawToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "antigravity":
|
|
||||||
return (
|
|
||||||
<AntigravityToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "cline":
|
|
||||||
return (
|
|
||||||
<ClineToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "kilo":
|
|
||||||
return (
|
|
||||||
<KiloToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "copilot":
|
|
||||||
return (
|
|
||||||
<CopilotToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "hermes-agent":
|
|
||||||
return (
|
|
||||||
<HermesAgentToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "custom":
|
|
||||||
return (
|
|
||||||
<CustomCliCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
availableModels={availableModels}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
// #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls)
|
|
||||||
if (tool.configType === "mitm") {
|
|
||||||
return (
|
|
||||||
<AntigravityToolCard
|
|
||||||
key={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
hasActiveProviders={hasActiveProviders}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DefaultToolCard
|
|
||||||
key={toolId}
|
|
||||||
toolId={toolId}
|
|
||||||
{...commonProps}
|
|
||||||
activeProviders={getActiveProviders()}
|
|
||||||
cloudEnabled={cloudEnabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
|
||||||
<span className="material-symbols-outlined text-[20px]">tips_and_updates</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-sm font-semibold">{t("howItWorks")}</h2>
|
|
||||||
<div className="mt-2 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-text-muted">
|
|
||||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
|
||||||
{t("installationGuide")}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
|
||||||
{t("configureEndpoint")}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
|
|
||||||
{t("testConnection")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">{t("toolCategories")}</h2>
|
|
||||||
<p className="text-xs text-text-muted mt-1">{t("toolCategoriesDesc")}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{t("visibleToolsCount", { count: toolEntries.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<SegmentedControl
|
|
||||||
options={[
|
|
||||||
{ value: "auto", label: t("autoConfiguredTab") },
|
|
||||||
{ value: "guided", label: t("guidedClientsTab") },
|
|
||||||
{ value: "mitm", label: t("mitmClientsTab") },
|
|
||||||
{
|
|
||||||
value: "custom",
|
|
||||||
label: translateOrFallback("customCliTab", "Custom CLI"),
|
|
||||||
},
|
|
||||||
{ value: "all", label: t("allToolsTab") },
|
|
||||||
]}
|
|
||||||
value={activeCategory}
|
|
||||||
onChange={setActiveCategory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{!hasActiveProviders && (
|
|
||||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
|
||||||
{t("noActiveProviders")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-text-muted">{t("noActiveProvidersDesc")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{toolEntries.map(([toolId, tool]) => {
|
|
||||||
const docsHref = getToolDocsHref(toolId, tool);
|
|
||||||
const isExternalDocs = /^https?:\/\//i.test(docsHref);
|
|
||||||
return (
|
|
||||||
<div key={toolId} className="flex flex-col gap-2.5">
|
|
||||||
{renderToolCard(toolId, tool)}
|
|
||||||
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2.5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-text-muted">
|
|
||||||
{t("whenToUseLabel")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-text-muted mt-1 break-words">
|
|
||||||
{getToolUseCase(toolId, tool)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={docsHref}
|
|
||||||
target={isExternalDocs ? "_blank" : undefined}
|
|
||||||
rel={isExternalDocs ? "noopener noreferrer" : undefined}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
|
||||||
menu_book
|
|
||||||
</span>
|
|
||||||
{t("openToolDocs")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { getMachineId } from "@/shared/utils/machine";
|
|
||||||
import CLIToolsPageClient from "./CLIToolsPageClient";
|
|
||||||
|
|
||||||
export default async function CLIToolsPage() {
|
|
||||||
const machineId = await getMachineId();
|
|
||||||
return <CLIToolsPageClient machineId={machineId} />;
|
|
||||||
}
|
|
||||||
202
tests/unit/ui/ToolDetailClient.test.tsx
Normal file
202
tests/unit/ui/ToolDetailClient.test.tsx
Normal file
@@ -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<HTMLAnchorElement> & { href: string }) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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: () => <div data-testid="ClaudeToolCard" />,
|
||||||
|
CodexToolCard: () => <div data-testid="CodexToolCard" />,
|
||||||
|
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
||||||
|
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
||||||
|
ClineToolCard: () => <div data-testid="ClineToolCard" />,
|
||||||
|
KiloToolCard: () => <div data-testid="KiloToolCard" />,
|
||||||
|
DefaultToolCard: ({ toolId }: { toolId: string }) => (
|
||||||
|
<div data-testid="DefaultToolCard" data-toolid={toolId} />
|
||||||
|
),
|
||||||
|
AntigravityToolCard: () => <div data-testid="AntigravityToolCard" />,
|
||||||
|
CopilotToolCard: () => <div data-testid="CopilotToolCard" />,
|
||||||
|
CustomCliCard: () => <div data-testid="CustomCliCard" />,
|
||||||
|
HermesAgentToolCard: () => <div data-testid="HermesAgentToolCard" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard", () => ({
|
||||||
|
default: () => <div data-testid="CliproxyapiToolCard" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 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(<ToolDetailClient toolId={toolId} category={category} />);
|
||||||
|
});
|
||||||
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/unit/ui/cli-agents-detail-page.test.tsx
Normal file
123
tests/unit/ui/cli-agents-detail-page.test.tsx
Normal file
@@ -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<HTMLAnchorElement> & { href: string }) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
tests/unit/ui/cli-code-detail-page.test.tsx
Normal file
127
tests/unit/ui/cli-code-detail-page.test.tsx
Normal file
@@ -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<HTMLAnchorElement> & { href: string }) => (
|
||||||
|
<a href={href} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<div data-testid="ToolDetailClient" data-toolid={toolId} data-category={category} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user