Files
SuperCharged-Claude-Code-Up…/dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
admin b52318eeae feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 00:27:56 +04:00

783 lines
32 KiB
TypeScript

/**
* CustomizePanel - Parent coordinator for agent configuration editing
*
* Responsibilities:
* - Load/save configuration via API
* - Mode switching (Form ↔ YAML)
* - YAML ↔ Config object conversion
* - Unsaved changes detection
* - Validation orchestration
*
* The actual editing is delegated to:
* - YAMLEditorView - for YAML mode
* - FormEditorView - for Form mode
*
* TODO: Future optimization - derive form metadata from schemas
* Currently form sections have manual field definitions. Consider deriving:
* - Required/optional fields from schema
* - Default values from schema defaults
* - Enum options from schema enums
* - Field types from schema types
* This would eliminate hardcoded UI metadata and reduce maintenance.
* See packages/core/src/utils/schema-metadata.ts for the core utilities that enable this (needs runtime fixes).
* This TODO is linked with the corresponding TODO in schema-metadata.ts tracking the same goal.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDebounce } from 'use-debounce';
import { Button } from '../ui/button';
import { X, Save, RefreshCw, AlertTriangle, CheckCircle, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
useAgentConfig,
useValidateAgent,
useSaveAgentConfig,
type ValidationError,
type ValidationWarning,
} from '../hooks/useAgentConfig';
import YAMLEditorView from './YAMLEditorView';
import FormEditorView from './FormEditorView';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip';
import * as yaml from 'yaml';
import type { AgentConfig } from '@dexto/core';
interface CustomizePanelProps {
isOpen: boolean;
onClose: () => void;
variant?: 'overlay' | 'inline';
}
type EditorMode = 'form' | 'yaml';
export default function CustomizePanel({
isOpen,
onClose,
variant = 'overlay',
}: CustomizePanelProps) {
// TanStack Query hooks
const {
data: configData,
isLoading,
error: loadError,
refetch: refetchConfig,
} = useAgentConfig(isOpen);
const validateMutation = useValidateAgent();
const saveMutation = useSaveAgentConfig();
// Content state
const [yamlContent, setYamlContent] = useState<string>('');
const [originalYamlContent, setOriginalYamlContent] = useState<string>('');
const [parsedConfig, setParsedConfig] = useState<AgentConfig | null>(null);
const [originalParsedConfig, setOriginalParsedConfig] = useState<AgentConfig | null>(null);
const [yamlDocument, setYamlDocument] = useState<yaml.Document | null>(null);
const [relativePath, setRelativePath] = useState<string>('');
// Editor mode
const [editorMode, setEditorMode] = useState<EditorMode>('yaml');
const [parseError, setParseError] = useState<string | null>(null);
// Validation state
const [isValid, setIsValid] = useState(true);
const [errors, setErrors] = useState<ValidationError[]>([]);
const [warnings, setWarnings] = useState<ValidationWarning[]>([]);
// Unsaved changes
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Save state (for success messages)
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveMessage, setSaveMessage] = useState<string>('');
// Debounced validation
const [debouncedYamlContent] = useDebounce(yamlContent, 500);
const latestValidationRequestRef = useRef(0);
// Validate YAML content via API
const validateYaml = useCallback(
async (yaml: string) => {
const requestId = latestValidationRequestRef.current + 1;
latestValidationRequestRef.current = requestId;
try {
const data = await validateMutation.mutateAsync({ yaml });
if (latestValidationRequestRef.current === requestId) {
setIsValid(data.valid);
setErrors(data.errors || []);
setWarnings(data.warnings || []);
}
} catch (err: any) {
console.warn(
`Validation error: ${err instanceof Error ? err.message : String(err)}`
);
if (latestValidationRequestRef.current === requestId) {
setIsValid(false);
setErrors([
{ message: 'Failed to validate configuration', code: 'VALIDATION_ERROR' },
]);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[validateMutation.mutateAsync]
);
// Initialize state when config data loads
useEffect(() => {
if (configData && isOpen) {
setYamlContent(configData.yaml);
setOriginalYamlContent(configData.yaml);
setRelativePath(configData.relativePath);
setHasUnsavedChanges(false);
// Parse for form mode
const { config, document } = parseYamlToConfig(configData.yaml);
if (config && document) {
setParsedConfig(config);
setOriginalParsedConfig(config);
setYamlDocument(document);
}
// Initial validation
validateYaml(configData.yaml);
}
}, [configData, isOpen, validateYaml]);
// Parse YAML to config object and document
const parseYamlToConfig = (
yamlString: string
): { config: AgentConfig | null; document: yaml.Document | null; error: string | null } => {
console.log('[parseYamlToConfig] Starting parse');
try {
const document = yaml.parseDocument(yamlString);
console.log('[parseYamlToConfig] Document created:', document);
// Check for parse errors
if (document.errors && document.errors.length > 0) {
console.debug('[parseYamlToConfig] Parse errors:', document.errors);
const message = document.errors.map((e) => e.message).join('; ');
return { config: null, document: null, error: message };
}
const config = document.toJS() as AgentConfig;
console.log('[parseYamlToConfig] Config parsed successfully:', config);
return { config, document, error: null };
} catch (err: unknown) {
console.debug('[parseYamlToConfig] Exception:', err);
const message = err instanceof Error ? err.message : 'Failed to parse YAML';
return { config: null, document: null, error: message };
}
};
// Update YAML document from config object while preserving comments
const updateYamlDocumentFromConfig = (
document: yaml.Document,
config: AgentConfig
): yaml.Document => {
console.log('[updateYamlDocumentFromConfig] Starting update');
console.log('[updateYamlDocumentFromConfig] Document:', document);
console.log('[updateYamlDocumentFromConfig] Config:', config);
const updateNode = (node: any, value: any): any => {
// Handle null/undefined
if (value === null || value === undefined) {
return document.createNode(value);
}
// Handle arrays - create new sequence
if (Array.isArray(value)) {
return document.createNode(value);
}
// Handle objects - update map recursively
if (typeof value === 'object' && !Array.isArray(value)) {
if (!node || !node.items) {
// Create new map if node doesn't exist
return document.createNode(value);
}
// Update existing map
const existingKeys = new Set<string>();
// Update existing keys and track them
for (const pair of node.items) {
const key = pair.key.value;
existingKeys.add(key);
if (key in value) {
// Update the value while preserving the pair (and its comments)
pair.value = updateNode(pair.value, value[key]);
}
}
// Add new keys
for (const [key, val] of Object.entries(value)) {
if (!existingKeys.has(key)) {
node.items.push(document.createPair(key, val));
}
}
// Remove keys not in new config
node.items = node.items.filter((pair: any) => {
const key = pair.key.value;
return key in value;
});
return node;
}
// Handle primitives - create new scalar
return document.createNode(value);
};
try {
// Update the root contents
document.contents = updateNode(document.contents, config);
console.log('[updateYamlDocumentFromConfig] Update successful');
return document;
} catch (err) {
console.error('[updateYamlDocumentFromConfig] Update failed:', err);
throw err;
}
};
// Generic deep cleanup to remove null/undefined/empty values
const cleanupConfig = (config: AgentConfig): AgentConfig => {
const isEmptyValue = (value: unknown): boolean => {
// null and undefined are empty
if (value === null || value === undefined) return true;
// Empty string is empty
if (value === '') return true;
// Empty arrays are empty
if (Array.isArray(value) && value.length === 0) return true;
// Empty objects are empty (but not Date, etc)
if (
typeof value === 'object' &&
value !== null &&
Object.prototype.toString.call(value) === '[object Object]' &&
Object.keys(value).length === 0
) {
return true;
}
// Everything else (including false, 0, etc) is not empty
return false;
};
const deepCleanup = (obj: any): any => {
if (Array.isArray(obj)) {
// For arrays, recursively clean each element and filter out empty ones
return obj.map(deepCleanup).filter((item) => !isEmptyValue(item));
}
if (typeof obj === 'object' && obj !== null) {
const cleaned: any = {};
for (const [key, value] of Object.entries(obj)) {
// Skip empty values
if (isEmptyValue(value)) {
continue;
}
// Recursively clean objects and arrays
if (typeof value === 'object' && value !== null) {
const cleanedValue = deepCleanup(value);
// Only add if the cleaned value is not empty
if (!isEmptyValue(cleanedValue)) {
cleaned[key] = cleanedValue;
}
} else {
// Keep non-object, non-empty values
cleaned[key] = value;
}
}
return cleaned;
}
// Return primitives as-is
return obj;
};
return deepCleanup(config) as AgentConfig;
};
// Serialize config back to YAML while preserving comments
const serializeConfigToYaml = (config: AgentConfig, document: yaml.Document): string => {
console.log('[serializeConfigToYaml] Starting serialization');
console.log('[serializeConfigToYaml] Document:', document);
console.log('[serializeConfigToYaml] Config:', config);
// Clean up config to remove null/undefined optional fields
const cleanedConfig = cleanupConfig(config);
console.log('[serializeConfigToYaml] Cleaned config:', cleanedConfig);
// Update document with new config and serialize with comments preserved
const updatedDoc = updateYamlDocumentFromConfig(document, cleanedConfig);
const result = updatedDoc.toString();
console.log('[serializeConfigToYaml] Serialized result length:', result.length);
return result;
};
// Deep comparison helper for configs
const configsAreEqual = (a: AgentConfig | null, b: AgentConfig | null): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
};
// Handle YAML editor changes
const handleYamlChange = (value: string) => {
setYamlContent(value);
setHasUnsavedChanges(value !== originalYamlContent);
setSaveSuccess(false);
// Update parsed config and document for potential form mode switch
const { config, document } = parseYamlToConfig(value);
if (config && document) {
setParsedConfig(config);
setYamlDocument(document);
}
// Validation happens automatically via debouncedYamlContent useEffect
};
// Handle form editor changes
const handleFormChange = (newConfig: AgentConfig) => {
console.log('[handleFormChange] Called with new config');
console.log('[handleFormChange] yamlDocument exists?', !!yamlDocument);
if (!yamlDocument) {
console.error('[handleFormChange] No document available - this should not happen!');
return;
}
setParsedConfig(newConfig);
// Use document to preserve comments
const newYaml = serializeConfigToYaml(newConfig, yamlDocument);
setYamlContent(newYaml);
// Use semantic comparison for form mode to handle YAML formatting differences
setHasUnsavedChanges(!configsAreEqual(newConfig, originalParsedConfig));
setSaveSuccess(false);
// Validation happens automatically via debouncedYamlContent useEffect
};
// Handle mode switch
const handleModeSwitch = (newMode: EditorMode) => {
console.log(
'[handleModeSwitch] Called with newMode:',
newMode,
'current mode:',
editorMode
);
if (newMode === editorMode) {
console.log('[handleModeSwitch] Same mode, returning');
return;
}
if (newMode === 'form') {
console.log('[handleModeSwitch] Switching to form mode, parsing YAML...');
// Switching to form mode - ensure config is parsed
const { config, document, error } = parseYamlToConfig(yamlContent);
console.log('[handleModeSwitch] Parse result:', { config, document, error });
if (error) {
console.error('[handleModeSwitch] Parse error, not switching:', error);
setParseError(error);
// Don't switch modes if parsing fails
return;
}
console.log('[handleModeSwitch] Parse successful, setting state');
setParsedConfig(config);
setYamlDocument(document);
setParseError(null);
}
console.log('[handleModeSwitch] Setting editor mode to:', newMode);
setEditorMode(newMode);
};
// Save configuration
const handleSave = useCallback(async () => {
if (!isValid || errors.length > 0) {
return;
}
setSaveSuccess(false);
setSaveMessage('');
try {
const data = await saveMutation.mutateAsync({ yaml: yamlContent });
setOriginalYamlContent(yamlContent);
setHasUnsavedChanges(false);
setSaveSuccess(true);
if (data.restarted) {
setSaveMessage(
`Configuration applied successfully — ${data.changesApplied.join(', ')} updated`
);
} else {
setSaveMessage('Configuration saved successfully (no changes detected)');
}
// Clear success message after 5 seconds
setTimeout(() => {
setSaveSuccess(false);
setSaveMessage('');
}, 5000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Error saving agent config: ${message}`);
}
}, [isValid, errors, saveMutation, yamlContent]);
// Reload configuration
const handleReload = () => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
} else {
refetchConfig();
}
};
// Handle close with unsaved changes check
const handleClose = useCallback(() => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true);
} else {
onClose();
}
}, [hasUnsavedChanges, onClose]);
// Confirm discard changes
const handleDiscardChanges = () => {
setShowUnsavedDialog(false);
setYamlContent(originalYamlContent);
// Also reset parsed config for form mode
if (originalParsedConfig) {
setParsedConfig(originalParsedConfig);
// Re-parse document for comment preservation
const { document } = parseYamlToConfig(originalYamlContent);
if (document) {
setYamlDocument(document);
}
}
setHasUnsavedChanges(false);
refetchConfig();
};
// Config loads automatically via useAgentConfig hook when isOpen is true
// Trigger validation when debounced content changes
useEffect(() => {
if (isOpen) {
validateYaml(debouncedYamlContent);
}
}, [debouncedYamlContent, isOpen, validateYaml]);
// Keyboard shortcuts
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd+S / Ctrl+S to save
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (!saveMutation.isPending && isValid) {
handleSave();
}
}
// Escape to close
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, saveMutation.isPending, isValid, hasUnsavedChanges, handleSave, handleClose]);
if (!isOpen) return null;
// Calculate save button disabled reason
const getSaveDisabledReason = (): string | null => {
if (saveMutation.isPending) return null; // Not really disabled, just in progress
if (!hasUnsavedChanges) return 'No changes to save';
if (errors.length > 0) {
// Find the most relevant error
const firstError = errors[0];
if (firstError.path) {
return `Configuration error in ${firstError.path}: ${firstError.message}`;
}
return `Configuration error: ${firstError.message}`;
}
if (!isValid) return 'Configuration has validation errors';
return null;
};
const saveDisabledReason = getSaveDisabledReason();
const isSaveDisabled =
!hasUnsavedChanges || saveMutation.isPending || !isValid || errors.length > 0;
const panelContent = (
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Customize Agent</h2>
<a
href="https://docs.dexto.ai/docs/guides/configuring-dexto/overview"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
title="View configuration documentation"
>
View docs
<ExternalLink className="h-3 w-3" />
</a>
</div>
{relativePath && (
<p className="text-xs text-muted-foreground">{relativePath}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Mode Toggle */}
<div className="flex items-center gap-1 bg-muted/50 rounded-md p-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={editorMode === 'yaml' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleModeSwitch('yaml')}
className="h-7 px-3"
>
YAML Editor
</Button>
</TooltipTrigger>
<TooltipContent>
Edit configuration in raw YAML format with full control
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={editorMode === 'form' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleModeSwitch('form')}
className="h-7 px-3"
>
Form Editor
</Button>
</TooltipTrigger>
<TooltipContent>
Edit configuration using user-friendly forms
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleReload}
disabled={isLoading}
>
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>Reload configuration</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Close (Esc)</TooltipContent>
</Tooltip>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{loadError ? (
<div className="flex items-center justify-center h-full p-4">
<div className="text-center max-w-md">
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">
Failed to load configuration
</h3>
<p className="text-sm text-muted-foreground mb-4">
{loadError?.message || 'Unknown error'}
</p>
<Button onClick={() => refetchConfig()} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
) : isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-4" />
<p className="text-sm text-muted-foreground">
Loading configuration...
</p>
</div>
</div>
) : parseError && editorMode === 'form' ? (
<div className="flex items-center justify-center h-full p-4">
<div className="text-center max-w-md">
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Cannot parse YAML</h3>
<p className="text-sm text-muted-foreground mb-4">{parseError}</p>
<Button onClick={() => setEditorMode('yaml')} variant="outline">
Switch to YAML Editor
</Button>
</div>
</div>
) : editorMode === 'yaml' ? (
<YAMLEditorView
value={yamlContent}
onChange={handleYamlChange}
isValidating={validateMutation.isPending}
isValid={isValid}
errors={errors}
warnings={warnings}
hasUnsavedChanges={hasUnsavedChanges}
/>
) : parsedConfig ? (
<FormEditorView
config={parsedConfig}
onChange={handleFormChange}
errors={errors.reduce(
(acc, err) => {
if (err.path) {
acc[err.path] = err.message;
}
return acc;
},
{} as Record<string, string>
)}
/>
) : null}
</div>
{/* Footer */}
{!loadError && !isLoading && (
<div className="flex flex-col border-t border-border">
{/* Save status messages */}
{(saveSuccess || saveMutation.error) && (
<div className="px-4 py-3 bg-muted/50 border-b border-border">
{saveSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-500">
<CheckCircle className="h-4 w-4" />
<span>{saveMessage}</span>
</div>
)}
{saveMutation.error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4" />
<span>{saveMutation.error.message}</span>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between px-4 py-3">
<div />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleClose}>
Close
</Button>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={isSaveDisabled}
>
{saveMutation.isPending ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent mr-2" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save
</>
)}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
{saveDisabledReason || 'Save configuration (⌘S)'}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}
{/* Unsaved changes dialog */}
<Dialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes. Do you want to discard them?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowUnsavedDialog(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
if (variant === 'inline') {
return panelContent;
}
return (
<>
{/* Backdrop */}
<div
className={cn(
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity duration-300',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={handleClose}
/>
{/* Panel */}
<div
className={cn(
'fixed inset-y-0 right-0 z-50 w-full sm:w-[600px] md:w-[700px] lg:w-[800px] border-l border-border/50 bg-card/95 backdrop-blur-xl shadow-2xl transform transition-transform duration-300',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
{panelContent}
</div>
</>
);
}