diff --git a/apps/studio/src/components/ai-input.tsx b/apps/studio/src/components/ai-input.tsx index e543e06ef0..b646375f5c 100644 --- a/apps/studio/src/components/ai-input.tsx +++ b/apps/studio/src/components/ai-input.tsx @@ -1,11 +1,10 @@ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Icon, moreVertical, keyboardReturn, reset, settings } from '@wordpress/icons'; +import { Icon, moreVertical, keyboardReturn, reset } from '@wordpress/icons'; import React, { forwardRef, useRef, useEffect, useState } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import { TELEX_HOSTNAME, TELEX_UTM_PARAMS } from 'src/constants'; import useAiIcon from 'src/hooks/use-ai-icon'; -import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { addUrlParams } from 'src/lib/url-utils'; @@ -18,7 +17,6 @@ interface AIInputProps { handleKeyDown: ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => void; clearConversation: () => void; isAssistantThinking: boolean; - onOpenSettings: () => void; } const MAX_ROWS = 10; @@ -56,11 +54,9 @@ const UnforwardedAIInput = ( handleKeyDown, clearConversation, isAssistantThinking, - onOpenSettings, }: AIInputProps, inputRef: React.RefObject< HTMLTextAreaElement > | React.RefCallback< HTMLTextAreaElement > | null ) => { - const { enableAgentSuite } = useFeatureFlags(); const [ isTyping, setIsTyping ] = useState( false ); const [ thinkingDuration, setThinkingDuration ] = useState< 'short' | 'medium' | 'long' | 'veryLong' @@ -259,18 +255,6 @@ const UnforwardedAIInput = ( { ( { onClose }: { onClose: () => void } ) => ( <> - { enableAgentSuite && ( - { - onOpenSettings(); - onClose(); - } } - > - - { __( 'AI settings' ) } - - ) } { diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx deleted file mode 100644 index 0007065971..0000000000 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useState } from 'react'; -import Button from 'src/components/button'; -import { InstalledBadge } from 'src/components/installed-badge'; -import Modal from 'src/components/modal'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { - INSTRUCTION_FILES, - type InstructionFileType, -} from 'src/modules/agent-instructions/constants'; -import { type InstructionFileStatus } from 'src/modules/agent-instructions/lib/instructions'; -import { - BUNDLED_SKILLS, - type SkillStatus, -} from 'src/modules/agent-instructions/lib/skills-constants'; - -interface AiSettingsModalProps { - isOpen: boolean; - onClose: () => void; - siteId: string; -} - -function AgentInstructionsPanel( { siteId }: { siteId: string } ) { - const { __ } = useI18n(); - const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); - const [ error, setError ] = useState< string | null >( null ); - const [ installingFile, setInstallingFile ] = useState< InstructionFileType | null >( null ); - - const refreshStatus = useCallback( async () => { - try { - const result = await getIpcApi().getAgentInstructionsStatus( siteId ); - setStatuses( result as InstructionFileStatus[] ); - setError( null ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } - }, [ siteId ] ); - - useEffect( () => { - void refreshStatus(); - const handleFocus = () => void refreshStatus(); - window.addEventListener( 'focus', handleFocus ); - return () => window.removeEventListener( 'focus', handleFocus ); - }, [ refreshStatus ] ); - - const handleInstallFile = useCallback( - async ( fileType: InstructionFileType, overwrite: boolean ) => { - setInstallingFile( fileType ); - setError( null ); - try { - await getIpcApi().installAgentInstructions( siteId, { overwrite, fileType } ); - await refreshStatus(); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } finally { - setInstallingFile( null ); - } - }, - [ siteId, refreshStatus ] - ); - - const allInstalled = statuses.length > 0 && statuses.every( ( s ) => s.exists ); - - const handleInstallAll = useCallback( async () => { - setError( null ); - for ( const status of statuses ) { - if ( ! status.exists ) { - await handleInstallFile( status.id, false ); - } - } - }, [ statuses, handleInstallFile ] ); - - return ( -
-
-
-

{ __( 'Agent instructions' ) }

-

- { __( 'Install instructions so agents know how to use Studio' ) } -

-
- { ! allInstalled && ( - - ) } -
- - { error && ( -
- { error } -
- ) } - -
- { statuses.map( ( status ) => { - const config = INSTRUCTION_FILES[ status.id ]; - const isInstalling = installingFile === status.id; - return ( -
-
-
- - { config.displayName } - - { status.exists && } -
-
- { __( config.description ) } -
-
-
- { status.exists && ( - - ) } - -
-
- ); - } ) } -
-
- ); -} - -function WordPressSkillsPanel( { siteId }: { siteId: string } ) { - const { __ } = useI18n(); - const [ statuses, setStatuses ] = useState< SkillStatus[] >( [] ); - const [ error, setError ] = useState< string | null >( null ); - const [ installing, setInstalling ] = useState( false ); - - const refreshStatus = useCallback( async () => { - try { - const result = await getIpcApi().getWordPressSkillsStatus( siteId ); - setStatuses( result as SkillStatus[] ); - setError( null ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } - }, [ siteId ] ); - - useEffect( () => { - void refreshStatus(); - const handleFocus = () => void refreshStatus(); - window.addEventListener( 'focus', handleFocus ); - return () => window.removeEventListener( 'focus', handleFocus ); - }, [ refreshStatus ] ); - - const handleInstall = useCallback( - async ( overwrite: boolean = false ) => { - setInstalling( true ); - setError( null ); - try { - await getIpcApi().installWordPressSkills( siteId, { overwrite } ); - await refreshStatus(); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } finally { - setInstalling( false ); - } - }, - [ siteId, refreshStatus ] - ); - - const allInstalled = statuses.length > 0 && statuses.every( ( s ) => s.installed ); - const installedCount = statuses.filter( ( s ) => s.installed ).length; - - return ( -
-
-
-

{ __( 'WordPress skills' ) }

-

- { __( 'WordPress development skills for AI agents' ) } -

-
-
- - { error && ( -
- { error } -
- ) } - -
-
-
-
- - { __( 'WordPress Skills' ) } - - { allInstalled && } - { ! allInstalled && installedCount > 0 && ( - - { `${ installedCount }/${ BUNDLED_SKILLS.length }` } - - ) } -
-
- { __( 'Plugins, blocks, themes, REST API, and WP-CLI skills' ) } -
-
-
- -
-
-
-
- ); -} - -export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalProps ) { - const { __ } = useI18n(); - - if ( ! isOpen ) { - return null; - } - - return ( - -
- - -
-
- ); -} diff --git a/apps/studio/src/components/content-tab-assistant.tsx b/apps/studio/src/components/content-tab-assistant.tsx index a98ecac96d..ee47a2247e 100644 --- a/apps/studio/src/components/content-tab-assistant.tsx +++ b/apps/studio/src/components/content-tab-assistant.tsx @@ -9,7 +9,6 @@ import { useI18n } from '@wordpress/react-i18n'; import React, { useState, useEffect, useRef, memo, useCallback, useMemo, forwardRef } from 'react'; import ClearHistoryReminder from 'src/components/ai-clear-history-reminder'; import { AIInput } from 'src/components/ai-input'; -import { AiSettingsModal } from 'src/components/ai-settings-modal'; import { ArrowIcon } from 'src/components/arrow-icon'; import { MessageThinking } from 'src/components/assistant-thinking'; import Button from 'src/components/button'; @@ -428,8 +427,6 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps dispatch( chatActions.setChatApiId( { instanceId, chatApiId: undefined } ) ); }; - const [ isAiSettingsModalOpen, setIsAiSettingsModalOpen ] = useState( false ); - // We should render only one notice at a time in the bottom area const renderNotice = () => { if ( isOffline ) { @@ -548,7 +545,6 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps } } clearConversation={ clearConversation } isAssistantThinking={ isAssistantThinking } - onOpenSettings={ () => setIsAiSettingsModalOpen( true ) } />
{ createInterpolateElement( __( 'Powered by experimental AI. ' ), { @@ -559,11 +555,6 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
- setIsAiSettingsModalOpen( false ) } - siteId={ selectedSite.id } - /> ); } diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index b565e7fc04..013e366bfc 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -8,6 +8,7 @@ import { import { moreVertical } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import StudioButton from 'src/components/button'; import { CopyTextButton } from 'src/components/copy-text-button'; import { LearnHowLink } from 'src/components/learn-more'; import { SettingsMenuItem } from 'src/components/settings-site-menu'; @@ -59,9 +60,14 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) await dispatch( certificateTrustApi.util.invalidateTags( [ 'CertificateTrust' ] ) ); }; const { handleDeleteSite } = useDeleteSite(); - const { copySite } = useSiteDetails(); + const { copySite, setIsEditModalOpen, setEditModalInitialTab } = useSiteDetails(); const [ debugLogPath, setDebugLogPath ] = useState< string | null >( null ); + const openEditModal = ( tab: string ) => { + setEditModalInitialTab( tab ); + setIsEditModalOpen( true ); + }; + const checkDebugLogExists = useCallback( async () => { if ( ! selectedSite.enableDebugLog ) { setDebugLogPath( null ); @@ -226,6 +232,33 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) +
+

{ __( 'AI Skills' ) }

+

+ { __( "Your task agents make use of skills you've installed in" ) }{ ' ' } + + { '. ' } + { __( 'You can override global skills for this site.' ) } +

+ openEditModal( 'skills' ) }> + { __( 'Manage site skills' ) } + +
+
+

+ { __( 'Agent Instructions' ) } +

+

+ { __( + 'Install instruction files like AGENTS.md so AI agents know how to work with this site.' + ) } +

+ openEditModal( 'instructions' ) }> + { __( 'Manage instructions' ) } + +
); } diff --git a/apps/studio/src/components/site-settings-panels.tsx b/apps/studio/src/components/site-settings-panels.tsx new file mode 100644 index 0000000000..40d2dc886e --- /dev/null +++ b/apps/studio/src/components/site-settings-panels.tsx @@ -0,0 +1,409 @@ +import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; +import { moreVertical } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Button from 'src/components/button'; +import { InstalledBadge } from 'src/components/installed-badge'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { + INSTRUCTION_FILES, + type InstructionFileType, +} from 'src/modules/agent-instructions/constants'; +import { type InstructionFileStatus } from 'src/modules/agent-instructions/lib/instructions'; +import { type SkillStatus } from 'src/modules/agent-instructions/lib/skills-constants'; + +export function AgentInstructionsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); + const [ error, setError ] = useState< string | null >( null ); + const [ installingFile, setInstallingFile ] = useState< InstructionFileType | null >( null ); + const [ installingAll, setInstallingAll ] = useState( false ); + + const refreshStatus = useCallback( async () => { + try { + const result = await getIpcApi().getAgentInstructionsStatus( siteId ); + setStatuses( result as InstructionFileStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } + }, [ siteId ] ); + + useEffect( () => { + void refreshStatus(); + const handleFocus = () => void refreshStatus(); + window.addEventListener( 'focus', handleFocus ); + return () => window.removeEventListener( 'focus', handleFocus ); + }, [ refreshStatus ] ); + + const handleInstallFile = useCallback( + async ( fileType: InstructionFileType ) => { + setInstallingFile( fileType ); + setError( null ); + try { + await getIpcApi().installAgentInstructions( siteId, { fileType } ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingFile( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const handleRemoveFile = useCallback( + async ( fileType: InstructionFileType ) => { + setError( null ); + try { + await getIpcApi().removeAgentInstruction( siteId, fileType ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } + }, + [ siteId, refreshStatus ] + ); + + const installedFiles = useMemo( () => statuses.filter( ( s ) => s.exists ), [ statuses ] ); + const availableFiles = useMemo( () => statuses.filter( ( s ) => ! s.exists ), [ statuses ] ); + + const handleInstallAll = useCallback( async () => { + setInstallingAll( true ); + setError( null ); + try { + for ( const status of availableFiles ) { + await handleInstallFile( status.id ); + } + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingAll( false ); + } + }, [ availableFiles, handleInstallFile ] ); + + const isAnyInstalling = installingFile !== null || installingAll; + + return ( +
+

+ { __( 'Install instruction files so AI agents know how to work with this site.' ) } +

+ + { error && ( +
+ { error } +
+ ) } + + { installedFiles.length > 0 && ( +
+
+ + { __( 'Installed' ) } + +
+ { installedFiles.map( ( status ) => { + const config = INSTRUCTION_FILES[ status.id ]; + return ( +
+
+
+ + { config.displayName } + + +
+
+ { __( config.description ) } +
+
+ + { ( { onClose }: { onClose: () => void } ) => ( + + { + getIpcApi().openFileInIDE( config.fileName, siteId ); + onClose(); + } } + > + { __( 'Open' ) } + + { + void handleRemoveFile( status.id ); + onClose(); + } } + > + { __( 'Remove' ) } + + + ) } + +
+ ); + } ) } +
+ ) } + + { availableFiles.length > 0 && ( +
+
+ + { __( 'Available' ) } + + +
+ { availableFiles.map( ( status ) => { + const config = INSTRUCTION_FILES[ status.id ]; + const isInstallingThis = installingFile === status.id; + return ( +
+
+
{ config.displayName }
+
+ { __( config.description ) } +
+
+
+ +
+
+ ); + } ) } +
+ ) } + + { statuses.length === 0 && ! error && ( +
+ { __( 'Loading instructions...' ) } +
+ ) } +
+ ); +} + +export function WordPressSkillsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const [ statuses, setStatuses ] = useState< SkillStatus[] >( [] ); + const [ error, setError ] = useState< string | null >( null ); + const [ installingSkillId, setInstallingSkillId ] = useState< string | null >( null ); + const [ installingAll, setInstallingAll ] = useState( false ); + + const refreshStatus = useCallback( async () => { + try { + const result = await getIpcApi().getWordPressSkillsStatus( siteId ); + setStatuses( result as SkillStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } + }, [ siteId ] ); + + useEffect( () => { + void refreshStatus(); + const handleFocus = () => void refreshStatus(); + window.addEventListener( 'focus', handleFocus ); + return () => window.removeEventListener( 'focus', handleFocus ); + }, [ refreshStatus ] ); + + const handleInstallSkill = useCallback( + async ( skillId: string ) => { + setInstallingSkillId( skillId ); + setError( null ); + try { + await getIpcApi().installWordPressSkillById( siteId, skillId ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingSkillId( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const handleRemoveSkill = useCallback( + async ( skillId: string ) => { + setError( null ); + try { + await getIpcApi().removeWordPressSkillById( siteId, skillId ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } + }, + [ siteId, refreshStatus ] + ); + + const installedSkills = useMemo( () => statuses.filter( ( s ) => s.installed ), [ statuses ] ); + const availableSkills = useMemo( () => statuses.filter( ( s ) => ! s.installed ), [ statuses ] ); + + const handleInstallAll = useCallback( async () => { + setInstallingAll( true ); + setError( null ); + try { + for ( const skill of availableSkills ) { + await getIpcApi().installWordPressSkillById( siteId, skill.id ); + } + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingAll( false ); + } + }, [ siteId, availableSkills, refreshStatus ] ); + + const isAnyInstalling = installingSkillId !== null || installingAll; + + return ( +
+

+ { __( + 'Manage skills for this site. These override the global skills from Studio Settings.' + ) } +

+ + { error && ( +
+ { error } +
+ ) } + + { installedSkills.length > 0 && ( +
+
+ + { __( 'Installed' ) } + +
+ { installedSkills.map( ( skill ) => ( +
+
+
+ { skill.displayName } + +
+
{ skill.description }
+
+ + { ( { onClose }: { onClose: () => void } ) => ( + + { + getIpcApi().openFileInIDE( + `.agents/skills/${ skill.id }/SKILL.md`, + siteId + ); + onClose(); + } } + > + { __( 'Open' ) } + + { + void handleRemoveSkill( skill.id ); + onClose(); + } } + > + { __( 'Remove' ) } + + + ) } + +
+ ) ) } +
+ ) } + + { availableSkills.length > 0 && ( +
+
+ + { __( 'Available' ) } + + +
+ { availableSkills.map( ( skill ) => { + const isInstallingThis = installingSkillId === skill.id; + return ( +
+
+
{ skill.displayName }
+
{ skill.description }
+
+
+ +
+
+ ); + } ) } +
+ ) } + + { statuses.length === 0 && ! error && ( +
{ __( 'Loading skills...' ) }
+ ) } +
+ ); +} diff --git a/apps/studio/src/components/tests/ai-input.test.tsx b/apps/studio/src/components/tests/ai-input.test.tsx index bb5652a169..19984f7d02 100644 --- a/apps/studio/src/components/tests/ai-input.test.tsx +++ b/apps/studio/src/components/tests/ai-input.test.tsx @@ -49,7 +49,6 @@ describe( 'AIInput Component', () => { handleKeyDown={ handleKeyDown } clearConversation={ clearConverstaion } isAssistantThinking={ defaultProps.isAssistantThinking } - onOpenSettings={ vi.fn() } /> ); } ); diff --git a/apps/studio/src/components/tests/content-tab-settings.test.tsx b/apps/studio/src/components/tests/content-tab-settings.test.tsx index fbd3e3bee1..7fbe998d1d 100644 --- a/apps/studio/src/components/tests/content-tab-settings.test.tsx +++ b/apps/studio/src/components/tests/content-tab-settings.test.tsx @@ -307,6 +307,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: false, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); const { rerender } = renderWithProvider( @@ -321,6 +323,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: true, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); rerenderWithProvider( rerender, ); await waitFor( () => { @@ -343,6 +347,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: false, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); rerenderWithProvider( rerender, ); @@ -379,6 +385,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: false, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); const { rerender } = renderWithProvider( @@ -393,6 +401,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: true, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); rerenderWithProvider( rerender, ); await waitFor( () => { @@ -414,6 +424,8 @@ describe( 'ContentTabSettings', () => { stopServer, isEditModalOpen: false, setIsEditModalOpen: vi.fn(), + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), } ); rerenderWithProvider( rerender, ); diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index 27a8c0364b..f8f2becf96 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -55,6 +55,8 @@ interface SiteDetailsContext { setUploadingSites: React.Dispatch< React.SetStateAction< { [ siteId: string ]: boolean } > >; isEditModalOpen: boolean; setIsEditModalOpen: React.Dispatch< React.SetStateAction< boolean > >; + editModalInitialTab: string; + setEditModalInitialTab: React.Dispatch< React.SetStateAction< string > >; siteCreationMessages: { [ siteId: string ]: string }; } @@ -80,6 +82,8 @@ const defaultContext: SiteDetailsContext = { setUploadingSites: () => undefined, isEditModalOpen: false, setIsEditModalOpen: () => undefined, + editModalInitialTab: 'general', + setEditModalInitialTab: () => undefined, }; export const siteDetailsContext = createContext< SiteDetailsContext >( defaultContext ); @@ -617,6 +621,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { }, [ sites, startServer ] ); const [ isEditModalOpen, setIsEditModalOpen ] = useState( false ); + const [ editModalInitialTab, setEditModalInitialTab ] = useState( 'general' ); const selectedSite = sites.find( ( site ) => site.id === selectedSiteId ) || firstSite; const isSiteDeleting = useCallback( @@ -646,6 +651,8 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { setUploadingSites, isEditModalOpen, setIsEditModalOpen, + editModalInitialTab, + setEditModalInitialTab, siteCreationMessages, } ), [ @@ -669,6 +676,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { uploadingSites, isEditModalOpen, setIsEditModalOpen, + editModalInitialTab, siteCreationMessages, ] ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 69fdd39486..23fc067fc7 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -80,12 +80,15 @@ import { type InstructionFileType } from 'src/modules/agent-instructions/constan import { getAllInstructionFilesStatus, installInstructionFile, + removeInstructionFile, type InstructionFileStatus, } from 'src/modules/agent-instructions/lib/instructions'; import { BUNDLED_SKILLS, getSkillsStatus, installAllSkills, + installSkillById, + removeSkillById, type SkillStatus, } from 'src/modules/agent-instructions/lib/skills'; import { editSiteViaCli, EditSiteOptions } from 'src/modules/cli/lib/cli-site-editor'; @@ -177,6 +180,18 @@ export async function installAgentInstructions( return installInstructionFile( server.details.path, fileType, overwrite ); } +export async function removeAgentInstruction( + _event: IpcMainInvokeEvent, + siteId: string, + fileType: InstructionFileType +): Promise< void > { + const server = SiteServer.get( siteId ); + if ( ! server ) { + throw new Error( `Site not found: ${ siteId }` ); + } + await removeInstructionFile( server.details.path, fileType ); +} + export async function getWordPressSkillsStatus( _event: IpcMainInvokeEvent, siteId: string @@ -201,6 +216,32 @@ export async function installWordPressSkills( await installAllSkills( server.details.path, overwrite ); } +export async function installWordPressSkillById( + _event: IpcMainInvokeEvent, + siteId: string, + skillId: string, + options?: { overwrite?: boolean } +): Promise< void > { + const server = SiteServer.get( siteId ); + if ( ! server ) { + throw new Error( `Site not found: ${ siteId }` ); + } + const overwrite = options?.overwrite ?? false; + await installSkillById( server.details.path, skillId, overwrite ); +} + +export async function removeWordPressSkillById( + _event: IpcMainInvokeEvent, + siteId: string, + skillId: string +): Promise< void > { + const server = SiteServer.get( siteId ); + if ( ! server ) { + throw new Error( `Site not found: ${ siteId }` ); + } + await removeSkillById( server.details.path, skillId ); +} + export async function getWordPressSkillsStatusAllSites( _event: IpcMainInvokeEvent ): Promise< SkillStatus[] > { diff --git a/apps/studio/src/modules/agent-instructions/lib/instructions.ts b/apps/studio/src/modules/agent-instructions/lib/instructions.ts index ad44270c27..38d49e7cfb 100644 --- a/apps/studio/src/modules/agent-instructions/lib/instructions.ts +++ b/apps/studio/src/modules/agent-instructions/lib/instructions.ts @@ -74,3 +74,11 @@ export async function installInstructionFile( await fs.writeFile( filePath, bundledContent, 'utf-8' ); return { path: filePath, overwritten: overwrite }; } + +export async function removeInstructionFile( + sitePath: string, + fileType: InstructionFileType +): Promise< void > { + const filePath = getInstructionFilePath( sitePath, fileType ); + await fs.rm( filePath, { force: true } ); +} diff --git a/apps/studio/src/modules/agent-instructions/lib/skills.ts b/apps/studio/src/modules/agent-instructions/lib/skills.ts index 2e3aef8a64..66fc2c4630 100644 --- a/apps/studio/src/modules/agent-instructions/lib/skills.ts +++ b/apps/studio/src/modules/agent-instructions/lib/skills.ts @@ -1,5 +1,5 @@ import nodePath from 'path'; -import { installSkillToSite } from '@studio/common/lib/agent-skills'; +import { installSkillToSite, removeSkillFromSite } from '@studio/common/lib/agent-skills'; import { pathExists } from '@studio/common/lib/fs-utils'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; import { BUNDLED_SKILLS, type SkillStatus } from './skills-constants'; @@ -31,3 +31,15 @@ export async function installAllSkills( } } } + +export async function installSkillById( + sitePath: string, + skillId: string, + overwrite: boolean = false +): Promise< void > { + await installSkillToSite( sitePath, getAiInstructionsPath(), skillId, overwrite ); +} + +export async function removeSkillById( sitePath: string, skillId: string ): Promise< void > { + await removeSkillFromSite( sitePath, skillId ); +} diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index d036beb6d1..3758ec9fb0 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -21,6 +21,7 @@ import { ErrorInformation } from 'src/components/error-information'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import Modal from 'src/components/modal'; import PasswordControl from 'src/components/password-control'; +import { AgentInstructionsPanel, WordPressSkillsPanel } from 'src/components/site-settings-panels'; import TextControlComponent from 'src/components/text-control'; import { Tooltip } from 'src/components/tooltip'; import { WPVersionSelector } from 'src/components/wp-version-selector'; @@ -36,7 +37,14 @@ type EditSiteDetailsProps = { const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) => { const { __ } = useI18n(); - const { updateSite, selectedSite, isEditModalOpen, setIsEditModalOpen } = useSiteDetails(); + const { + updateSite, + selectedSite, + isEditModalOpen, + setIsEditModalOpen, + editModalInitialTab, + setEditModalInitialTab, + } = useSiteDetails(); const [ errorUpdatingWpVersion, setErrorUpdatingWpVersion ] = useState< string | null >( null ); const [ isEditingSite, setIsEditingSite ] = useState( false ); const [ needsRestart, setNeedsRestart ] = useState( false ); @@ -287,7 +295,10 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = tabs={ [ { name: 'general', title: __( 'General' ) }, { name: 'debugging', title: __( 'Debugging' ) }, + { name: 'skills', title: __( 'Skills' ) }, + { name: 'instructions', title: __( 'Instructions' ) }, ] } + initialTabName={ editModalInitialTab } orientation="horizontal" > { ( { name } ) => ( @@ -591,6 +602,12 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ) } + { name === 'skills' && selectedSite && ( + + ) } + { name === 'instructions' && selectedSite && ( + + ) } ) } @@ -616,6 +633,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = disabled={ ! selectedSite } className="shrink-0" onClick={ () => { + setEditModalInitialTab( 'general' ); setIsEditModalOpen( true ); resetFormState(); } } diff --git a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx index 4c80ec645c..fda59a0efd 100644 --- a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx +++ b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx @@ -94,6 +94,8 @@ describe( 'EditSiteDetails', () => { startServer: mockStartServer, setIsEditModalOpen: vi.fn(), isEditModalOpen: false, + editModalInitialTab: 'general', + setEditModalInitialTab: vi.fn(), }; beforeEach( () => { diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index b99a88b003..b9397c3383 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -162,9 +162,15 @@ const api: IpcApi = { ipcRendererInvoke( 'getAgentInstructionsStatus', siteId ), installAgentInstructions: ( siteId, options ) => ipcRendererInvoke( 'installAgentInstructions', siteId, options ), + removeAgentInstruction: ( siteId, fileType ) => + ipcRendererInvoke( 'removeAgentInstruction', siteId, fileType ), getWordPressSkillsStatus: ( siteId ) => ipcRendererInvoke( 'getWordPressSkillsStatus', siteId ), installWordPressSkills: ( siteId, options ) => ipcRendererInvoke( 'installWordPressSkills', siteId, options ), + installWordPressSkillById: ( siteId, skillId, options ) => + ipcRendererInvoke( 'installWordPressSkillById', siteId, skillId, options ), + removeWordPressSkillById: ( siteId, skillId ) => + ipcRendererInvoke( 'removeWordPressSkillById', siteId, skillId ), getWordPressSkillsStatusAllSites: () => ipcRendererInvoke( 'getWordPressSkillsStatusAllSites' ), installWordPressSkillsToAllSites: ( options ) => ipcRendererInvoke( 'installWordPressSkillsToAllSites', options ), diff --git a/tools/common/lib/agent-skills.ts b/tools/common/lib/agent-skills.ts index 394c124ce1..74752e0aa1 100644 --- a/tools/common/lib/agent-skills.ts +++ b/tools/common/lib/agent-skills.ts @@ -9,6 +9,13 @@ import { isErrnoException } from './is-errno-exception'; */ const MANAGED_INSTRUCTION_FILES = [ 'STUDIO.md' ]; +export async function removeSkillFromSite( sitePath: string, skillId: string ): Promise< void > { + const agentsSkillPath = path.join( sitePath, '.agents', 'skills', skillId ); + const claudeSkillPath = path.join( sitePath, '.claude', 'skills', skillId ); + await fs.rm( agentsSkillPath, { recursive: true, force: true } ); + await fs.rm( claudeSkillPath, { recursive: true, force: true } ); +} + /** * Install all bundled AI instructions and skills from a source directory into a site. * @@ -84,13 +91,6 @@ async function installInstructionFile( await fs.copyFile( path.join( bundledPath, fileName ), dest ); } -export async function removeSkillFromSite( sitePath: string, skillId: string ): Promise< void > { - const agentsSkillPath = path.join( sitePath, '.agents', 'skills', skillId ); - const claudeSkillPath = path.join( sitePath, '.claude', 'skills', skillId ); - await fs.rm( agentsSkillPath, { recursive: true, force: true } ); - await fs.rm( claudeSkillPath, { recursive: true, force: true } ); -} - export async function installSkillToSite( sitePath: string, bundledPath: string,