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>
This commit is contained in:
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user