feat: implement platform-specific provider visibility for cursor agent

This commit is contained in:
Haileyesus
2026-04-16 23:21:21 +03:00
parent d979c315cd
commit 1a6eb57043
8 changed files with 113 additions and 11 deletions

View File

@@ -1,7 +1,18 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js'; import { AppError } from '@/shared/utils.js';
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
if (providerId === 'cursor' && os.platform() === 'win32') {
return false;
}
return true;
}
export const providerMcpService = { export const providerMcpService = {
/** /**
@@ -83,7 +94,7 @@ export const providerMcpService = {
const scope = input.scope ?? 'project'; const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = []; const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders(); const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
for (const provider of providers) { for (const provider of providers) {
try { try {
await provider.mcp.upsertServer({ ...input, scope }); await provider.mcp.upsertServer({ ...input, scope });

View File

@@ -255,7 +255,8 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
workspacePath, workspacePath,
}); });
assert.equal(globalResult.length, 4); const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
assert.ok(globalResult.every((entry) => entry.created === true)); assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json')); const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
@@ -267,8 +268,10 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json')); const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']); assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json')); if (expectCursorGlobal) {
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']); const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
}
await assert.rejects( await assert.rejects(
providerMcpService.addMcpServerToAllProviders({ providerMcpService.addMcpServerToAllProviders({

View File

@@ -273,4 +273,14 @@ router.post('/push/unsubscribe', async (req, res) => {
} }
}); });
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
router.get('/server-env', async (req, res) => {
try {
res.json({ platform: process.platform });
} catch (error) {
console.error('Error reading server environment:', error);
res.status(500).json({ error: 'Failed to read server environment' });
}
});
export default router; export default router;

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useEffect, useMemo } from "react";
import { Check, ChevronDown } from "lucide-react"; import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import { import {
CLAUDE_MODELS, CLAUDE_MODELS,
@@ -115,6 +116,23 @@ export default function ProviderSelectionEmptyState({
setInput, setInput,
}: ProviderSelectionEmptyStateProps) { }: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat"); const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
const visibleProviders = useMemo(
() =>
isWindowsServer
? PROVIDERS.filter((p) => p.id !== "cursor")
: PROVIDERS,
[isWindowsServer],
);
useEffect(() => {
if (isWindowsServer && provider === "cursor") {
setProvider("claude");
localStorage.setItem("selected-provider", "claude");
}
}, [isWindowsServer, provider, setProvider]);
const nextTaskPrompt = t("tasks.nextTaskPrompt", { const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task", defaultValue: "Start the next task",
}); });
@@ -166,8 +184,10 @@ export default function ProviderSelectionEmptyState({
</div> </div>
{/* Provider cards — horizontal row, equal width */} {/* Provider cards — horizontal row, equal width */}
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5"> <div
{PROVIDERS.map((p) => { className={`mb-6 grid grid-cols-2 gap-2 sm:gap-2.5 ${visibleProviders.length >= 4 ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}
>
{visibleProviders.map((p) => {
const active = provider === p.id; const active = provider === p.id;
return ( return (
<button <button

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
import type { AgentCategory, AgentProvider } from '../../../types/types'; import type { AgentCategory, AgentProvider } from '../../../types/types';
import type { AgentContext, AgentsSettingsTabProps } from './types'; import type { AgentContext, AgentsSettingsTabProps } from './types';
@@ -22,6 +23,22 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) { }: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude'); const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account'); const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
if (isWindowsServer) {
return all.filter((id) => id !== 'cursor');
}
return all;
}, [isWindowsServer]);
useEffect(() => {
if (isWindowsServer && selectedAgent === 'cursor') {
setSelectedAgent('claude');
}
}, [isWindowsServer, selectedAgent]);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({ const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: { claude: {
@@ -51,6 +68,7 @@ export default function AgentsSettingsTab({
return ( return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]"> <div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection <AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent} selectedAgent={selectedAgent}
onSelectAgent={setSelectedAgent} onSelectAgent={setSelectedAgent}
agentContextById={agentContextById} agentContextById={agentContextById}

View File

@@ -3,8 +3,6 @@ import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProvide
import type { AgentProvider } from '../../../../types/types'; import type { AgentProvider } from '../../../../types/types';
import type { AgentSelectorSectionProps } from '../types'; import type { AgentSelectorSectionProps } from '../types';
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
const AGENT_NAMES: Record<AgentProvider, string> = { const AGENT_NAMES: Record<AgentProvider, string> = {
claude: 'Claude', claude: 'Claude',
cursor: 'Cursor', cursor: 'Cursor',
@@ -13,6 +11,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
}; };
export default function AgentSelectorSection({ export default function AgentSelectorSection({
agents,
selectedAgent, selectedAgent,
onSelectAgent, onSelectAgent,
agentContextById, agentContextById,
@@ -20,7 +19,7 @@ export default function AgentSelectorSection({
return ( return (
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3"> <div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
<PillBar className="w-full md:w-auto"> <PillBar className="w-full md:w-auto">
{AGENT_PROVIDERS.map((agent) => { {agents.map((agent) => {
const dotColor = const dotColor =
agent === 'claude' ? 'bg-blue-500' : agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' : agent === 'cursor' ? 'bg-purple-500' :

View File

@@ -37,6 +37,7 @@ export type AgentCategoryTabsSectionProps = {
}; };
export type AgentSelectorSectionProps = { export type AgentSelectorSectionProps = {
agents: AgentProvider[];
selectedAgent: AgentProvider; selectedAgent: AgentProvider;
onSelectAgent: (agent: AgentProvider) => void; onSelectAgent: (agent: AgentProvider) => void;
agentContextById: AgentContextByProvider; agentContextById: AgentContextByProvider;

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
/**
* Node `process.platform` from the API host (e.g. win32, darwin, linux).
* Null until loaded or if the request fails.
*/
export function useServerPlatform(): {
serverPlatform: string | null;
isWindowsServer: boolean;
} {
const [serverPlatform, setServerPlatform] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const response = await authenticatedFetch('/api/settings/server-env');
if (!response.ok) {
return;
}
const body = (await response.json()) as { platform?: string };
if (!cancelled && typeof body.platform === 'string') {
setServerPlatform(body.platform);
}
} catch {
// Keep null: treat as unknown host.
}
})();
return () => {
cancelled = true;
};
}, []);
return {
serverPlatform,
isWindowsServer: serverPlatform === 'win32',
};
}