diff --git a/inc/Core/Admin/Pages/Agent/assets/react/components/SystemTasksTab.jsx b/inc/Core/Admin/Pages/Agent/assets/react/components/SystemTasksTab.jsx index 39aaca82..fc5a0458 100644 --- a/inc/Core/Admin/Pages/Agent/assets/react/components/SystemTasksTab.jsx +++ b/inc/Core/Admin/Pages/Agent/assets/react/components/SystemTasksTab.jsx @@ -2,15 +2,21 @@ * SystemTasksTab Component * * Card-based display of registered system tasks with trigger info, - * run history, enable/disable toggles, and Run Now functionality. + * run history, enable/disable toggles, Run Now, and editable AI prompts. * * @since 0.42.0 */ -import { useState } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { client } from '@shared/utils/api'; import { useUpdateSettings } from '@shared/queries/settings'; +import PromptField from '@shared/components/PromptField'; +import { + useSystemTaskPrompts, + useSavePrompt, + useResetPrompt, +} from '../queries/systemTaskPrompts'; const SYSTEM_TASKS_KEY = [ 'system-tasks' ]; @@ -94,16 +100,96 @@ const TRIGGER_ICONS = { tool: '\uD83E\uDD16', }; -const TaskCard = ( { task, onToggle, onRun, isToggling, isRunning } ) => { +/** + * PromptEditor — expandable section for a single prompt definition. + */ +const PromptEditor = ( { prompt, onSave, onReset } ) => { + const [ isResetting, setIsResetting ] = useState( false ); + const effectiveValue = prompt.has_override + ? prompt.override + : prompt.default; + + const handleSave = async ( newValue ) => { + return await onSave( prompt.task_type, prompt.prompt_key, newValue ); + }; + + const handleReset = async () => { + setIsResetting( true ); + try { + await onReset( prompt.task_type, prompt.prompt_key ); + } finally { + setIsResetting( false ); + } + }; + + const variableKeys = prompt.variables + ? Object.keys( prompt.variables ) + : []; + + return ( +
+ + + { variableKeys.length > 0 && ( +
+ + Variables: + + { variableKeys.map( ( key ) => ( + + { `{{${ key }}}` } + + ) ) } +
+ ) } + + { prompt.has_override && ( + + ) } +
+ ); +}; + +const TaskCard = ( { + task, + prompts, + onToggle, + onRun, + onSavePrompt, + onResetPrompt, + isToggling, + isRunning, +} ) => { + const [ showPrompts, setShowPrompts ] = useState( false ); const status = formatStatus( task.last_status ); const hasToggle = Boolean( task.setting_key ); const lastRunDate = formatDate( task.last_run_at ); const triggerIcon = TRIGGER_ICONS[ task.trigger_type ] || ''; + const hasPrompts = prompts.length > 0; return ( -
+
{ task.label } @@ -145,8 +231,7 @@ const TaskCard = ( { task, onToggle, onRun, isToggling, isRunning } ) => { { lastRunDate ? ( <> - { lastRunDate } - { ' ' } + { lastRunDate }{ ' ' } { status.label } @@ -169,18 +254,39 @@ const TaskCard = ( { task, onToggle, onRun, isToggling, isRunning } ) => {
- { task.supports_run && ( -
+
+ { task.supports_run && ( + ) } + + { hasPrompts && ( + + ) } +
+ + { showPrompts && hasPrompts && ( +
+ { prompts.map( ( prompt ) => ( + + ) ) }
) }
@@ -189,11 +295,32 @@ const TaskCard = ( { task, onToggle, onRun, isToggling, isRunning } ) => { const SystemTasksTab = () => { const { data: tasks, isLoading, error } = useSystemTasks(); + const { + data: allPrompts, + isLoading: promptsLoading, + } = useSystemTaskPrompts(); const updateMutation = useUpdateSettings(); const runMutation = useRunTask(); + const saveMutation = useSavePrompt(); + const resetMutation = useResetPrompt(); const queryClient = useQueryClient(); const [ runningTask, setRunningTask ] = useState( null ); + // Group prompts by task_type for efficient lookup. + const promptsByTask = useMemo( () => { + if ( ! allPrompts ) { + return {}; + } + const grouped = {}; + for ( const prompt of allPrompts ) { + if ( ! grouped[ prompt.task_type ] ) { + grouped[ prompt.task_type ] = []; + } + grouped[ prompt.task_type ].push( prompt ); + } + return grouped; + }, [ allPrompts ] ); + const handleToggle = async ( settingKey, currentValue ) => { try { await updateMutation.mutateAsync( { @@ -201,6 +328,7 @@ const SystemTasksTab = () => { } ); queryClient.invalidateQueries( { queryKey: SYSTEM_TASKS_KEY } ); } catch ( err ) { + // eslint-disable-next-line no-console console.error( 'Failed to toggle system task:', err ); } }; @@ -210,13 +338,31 @@ const SystemTasksTab = () => { try { await runMutation.mutateAsync( taskType ); } catch ( err ) { + // eslint-disable-next-line no-console console.error( 'Failed to run task:', err ); } finally { setRunningTask( null ); } }; - if ( isLoading ) { + const handleSavePrompt = async ( taskType, promptKey, prompt ) => { + try { + await saveMutation.mutateAsync( { + taskType, + promptKey, + prompt, + } ); + return { success: true }; + } catch ( err ) { + return { success: false, message: err.message }; + } + }; + + const handleResetPrompt = async ( taskType, promptKey ) => { + await resetMutation.mutateAsync( { taskType, promptKey } ); + }; + + if ( isLoading || promptsLoading ) { return (
@@ -247,16 +393,20 @@ const SystemTasksTab = () => { className="description" style={ { marginTop: '16px', marginBottom: '16px' } } > - Registered task definitions. Each task can run standalone via - Action Scheduler or as a step in a pipeline flow. + Registered task definitions. Each task can run via Action + Scheduler or as a step in a pipeline flow. Tasks with AI + prompts can be customized below.

{ tasks.map( ( task ) => ( diff --git a/inc/Core/Admin/Pages/Agent/assets/react/queries/systemTaskPrompts.js b/inc/Core/Admin/Pages/Agent/assets/react/queries/systemTaskPrompts.js new file mode 100644 index 00000000..41542ef1 --- /dev/null +++ b/inc/Core/Admin/Pages/Agent/assets/react/queries/systemTaskPrompts.js @@ -0,0 +1,77 @@ +/** + * System Task Prompts Queries + * + * TanStack Query hooks for system task prompt CRUD via REST API. + * + * @since 0.43.0 + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@shared/utils/api'; + +const PROMPTS_KEY = [ 'system-task-prompts' ]; + +/** + * Fetch all system task prompt definitions with overrides. + */ +export const useSystemTaskPrompts = () => + useQuery( { + queryKey: PROMPTS_KEY, + queryFn: async () => { + const result = await client.get( '/system/tasks/prompts' ); + if ( ! result.success ) { + throw new Error( + result.message || 'Failed to fetch task prompts' + ); + } + return result.data; + }, + staleTime: 60 * 1000, + } ); + +/** + * Save a prompt override. + */ +export const useSavePrompt = () => { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: async ( { taskType, promptKey, prompt } ) => { + const result = await client.put( + `/system/tasks/prompts/${ taskType }/${ promptKey }`, + { prompt } + ); + if ( ! result.success ) { + throw new Error( + result.message || 'Failed to save prompt' + ); + } + return result.data; + }, + onSuccess: () => { + queryClient.invalidateQueries( { queryKey: PROMPTS_KEY } ); + }, + } ); +}; + +/** + * Reset a prompt to its default. + */ +export const useResetPrompt = () => { + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: async ( { taskType, promptKey } ) => { + const result = await client.delete( + `/system/tasks/prompts/${ taskType }/${ promptKey }` + ); + if ( ! result.success ) { + throw new Error( + result.message || 'Failed to reset prompt' + ); + } + return result.data; + }, + onSuccess: () => { + queryClient.invalidateQueries( { queryKey: PROMPTS_KEY } ); + }, + } ); +}; diff --git a/inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineStepCard.jsx b/inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineStepCard.jsx index 2474994d..29f84c6f 100644 --- a/inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineStepCard.jsx +++ b/inc/Core/Admin/Pages/Pipelines/assets/react/components/pipelines/PipelineStepCard.jsx @@ -13,7 +13,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import PromptField from '../shared/PromptField'; +import PromptField from '@shared/components/PromptField'; import { updateSystemPrompt } from '../../utils/api'; import { useStepTypes, useTools } from '../../queries/config'; diff --git a/inc/Core/Admin/shared/components/PromptField.jsx b/inc/Core/Admin/shared/components/PromptField.jsx new file mode 100644 index 00000000..f6a68143 --- /dev/null +++ b/inc/Core/Admin/shared/components/PromptField.jsx @@ -0,0 +1,178 @@ +/** + * PromptField Component + * + * Reusable textarea field with auto-save (debounced), saving indicator, + * and error handling. Works for AI system prompts, flow step user messages, + * Agent Ping prompts, and any editable text field. + * + * @since 0.42.0 + */ + +/** + * WordPress dependencies + */ +import { useState, useEffect, useCallback, useRef } from '@wordpress/element'; +import { TextareaControl, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Auto-save delay in milliseconds. + */ +const AUTO_SAVE_DELAY = 500; + +/** + * PromptField Component + * + * @param {Object} props - Component props + * @param {string} props.value - Current field value + * @param {Function} props.onChange - Change handler (for immediate updates) + * @param {Function} props.onSave - Save handler (async, returns { success, message? }) + * @param {string} props.label - Field label + * @param {string} props.placeholder - Placeholder text + * @param {number} props.rows - Number of textarea rows (default: 6) + * @param {string} props.help - Help text (overridden when saving) + * @param {boolean} props.disabled - Whether the field is disabled + * @param {string} props.className - Additional CSS class + * @return {React.ReactElement} Prompt field component + */ +export default function PromptField( { + value = '', + onChange, + onSave, + label, + placeholder, + rows = 6, + help, + disabled = false, + className = '', +} ) { + const [ localValue, setLocalValue ] = useState( value ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ error, setError ] = useState( null ); + const saveTimeout = useRef( null ); + const lastSavedValue = useRef( value ); + + /** + * Sync local value with external value changes + */ + useEffect( () => { + setLocalValue( value ); + lastSavedValue.current = value; + }, [ value ] ); + + /** + * Save value to API + */ + const saveValue = useCallback( + async ( newValue ) => { + if ( ! onSave ) { + return; + } + + // Skip if unchanged + if ( newValue === lastSavedValue.current ) { + return; + } + + setIsSaving( true ); + setError( null ); + + try { + const result = await onSave( newValue ); + + if ( result?.success === false ) { + setError( + result.message || __( 'Failed to save', 'data-machine' ) + ); + // Revert to last saved value on error + setLocalValue( lastSavedValue.current ); + } else { + lastSavedValue.current = newValue; + } + } catch ( err ) { + // eslint-disable-next-line no-console + console.error( 'PromptField save error:', err ); + setError( + err.message || __( 'An error occurred', 'data-machine' ) + ); + // Revert to last saved value on error + setLocalValue( lastSavedValue.current ); + } finally { + setIsSaving( false ); + } + }, + [ onSave ] + ); + + /** + * Handle value change with debouncing + */ + const handleChange = useCallback( + ( newValue ) => { + setLocalValue( newValue ); + + // Call immediate onChange if provided + if ( onChange ) { + onChange( newValue ); + } + + // Clear existing timeout + if ( saveTimeout.current ) { + clearTimeout( saveTimeout.current ); + } + + // Set new timeout for debounced save + if ( onSave ) { + saveTimeout.current = setTimeout( () => { + saveValue( newValue ); + }, AUTO_SAVE_DELAY ); + } + }, + [ onChange, onSave, saveValue ] + ); + + /** + * Cleanup timeout on unmount + */ + useEffect( () => { + return () => { + if ( saveTimeout.current ) { + clearTimeout( saveTimeout.current ); + } + }; + }, [] ); + + /** + * Get help text with saving indicator + */ + const getHelpText = () => { + if ( isSaving ) { + return __( 'Saving…', 'data-machine' ); + } + return help; + }; + + return ( +
+ { error && ( + setError( null ) } + > + { error } + + ) } + + +
+ ); +}