From 11d0b85934707fe4a1d5ade618774894a66540e6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 16 Mar 2026 18:08:38 +0000 Subject: [PATCH] feat: add editable AI prompts to system tasks UI The backend infrastructure was fully built (getPromptDefinitions(), REST API, CLI, storage) but the admin UI never exposed it. Tasks with AI prompts now show an 'Edit Prompts' button that expands an inline editor with auto-save, variable hints, and reset-to-default. Changes: - Move PromptField to @shared/components/ for cross-bundle reuse - Update PipelineStepCard import to use @shared path - Add systemTaskPrompts query hooks (GET/PUT/DELETE) - Add PromptEditor and prompt section to SystemTasksTab TaskCard - Tasks with editable prompts: Alt Text, Meta Description, Internal Linking, Daily Memory (5 prompts total) --- .../react/components/SystemTasksTab.jsx | 180 ++++++++++++++++-- .../assets/react/queries/systemTaskPrompts.js | 77 ++++++++ .../components/pipelines/PipelineStepCard.jsx | 2 +- .../Admin/shared/components/PromptField.jsx | 178 +++++++++++++++++ 4 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 inc/Core/Admin/Pages/Agent/assets/react/queries/systemTaskPrompts.js create mode 100644 inc/Core/Admin/shared/components/PromptField.jsx 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 39aaca82c..fc5a04581 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 000000000..41542ef12 --- /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 2474994d1..29f84c6fe 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 000000000..f6a681433 --- /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 } + + ) } + + +
+ ); +}