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:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
/**
* AgentConfigEditor
*
* Monaco-based YAML editor component for editing agent configuration files.
* Provides syntax highlighting, line numbers, and configurable editor options.
* Validation is handled externally via the onValidate callback.
*/
import React, { useRef, useEffect } from 'react';
import Editor, { type OnMount } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
interface AgentConfigEditorProps {
value: string;
onChange: (value: string) => void;
onValidate?: (markers: editor.IMarker[]) => void;
readOnly?: boolean;
height?: string;
}
export default function AgentConfigEditor({
value,
onChange,
onValidate,
readOnly = false,
height = '100%',
}: AgentConfigEditorProps) {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
useEffect(() => {
// Set up validation when editor is mounted
if (editorRef.current && onValidate) {
const model = editorRef.current.getModel();
if (model) {
// Server-side validation is handled via API
// Monaco provides basic YAML syntax highlighting
}
}
}, [onValidate]);
const handleEditorDidMount: OnMount = (editorInstance) => {
editorRef.current = editorInstance as editor.IStandaloneCodeEditor;
// Configure editor options
editorInstance.updateOptions({
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
lineNumbers: 'on',
renderLineHighlight: 'all',
folding: true,
automaticLayout: true,
});
};
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
onChange(value);
}
};
return (
<Editor
height={height}
defaultLanguage="yaml"
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
theme="vs-dark"
options={{
readOnly,
wordWrap: 'on',
tabSize: 2,
}}
/>
);
}

View File

@@ -0,0 +1,111 @@
/**
* ConfigValidationStatus
*
* Displays real-time validation status for agent configuration editing.
* Shows validation state (validating/valid/invalid), error count, warnings,
* and detailed error/warning messages with line numbers. Provides visual
* feedback during configuration editing to help users fix issues before saving.
*/
import React from 'react';
import { AlertCircle, CheckCircle, AlertTriangle } from 'lucide-react';
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
interface ConfigValidationStatusProps {
isValidating: boolean;
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
hasUnsavedChanges: boolean;
}
export default function ConfigValidationStatus({
isValidating,
isValid,
errors,
warnings,
hasUnsavedChanges,
}: ConfigValidationStatusProps) {
return (
<div className="border-t border-border bg-background px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Status indicator */}
<div className="flex items-center gap-2 min-w-0 flex-1">
{isValidating ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Validating...</span>
</>
) : isValid ? (
<>
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Valid configuration
{hasUnsavedChanges && ' (unsaved changes)'}
</span>
</>
) : (
<>
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">
{errors.length} {errors.length === 1 ? 'error' : 'errors'}
</span>
</>
)}
</div>
{/* Warnings indicator */}
{warnings.length > 0 && (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
<span className="text-sm text-yellow-500">
{warnings.length} {warnings.length === 1 ? 'warning' : 'warnings'}
</span>
</div>
)}
</div>
{/* Error list */}
{errors.length > 0 && (
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
{errors.map((error, idx) => (
<div
key={idx}
className="text-xs bg-destructive/10 text-destructive rounded px-2 py-1.5 flex items-start gap-2"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
{error.path && <span className="font-medium">{error.path}: </span>}
{error.message}
{error.line && (
<span className="text-muted-foreground ml-1">
(line {error.line}
{error.column && `:${error.column}`})
</span>
)}
</div>
</div>
))}
</div>
)}
{/* Warning list */}
{warnings.length > 0 && errors.length === 0 && (
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
{warnings.map((warning, idx) => (
<div
key={idx}
className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 rounded px-2 py-1.5 flex items-start gap-2"
>
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{warning.path}: </span>
{warning.message}
</div>
</div>
))}
</div>
)}
</div>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,274 @@
import React, { useState, useEffect } from 'react';
import { LLMConfigSection } from './form-sections/LLMConfigSection';
import { SystemPromptSection } from './form-sections/SystemPromptSection';
import { McpServersSection } from './form-sections/McpServersSection';
import { StorageSection } from './form-sections/StorageSection';
import { ToolConfirmationSection } from './form-sections/ToolConfirmationSection';
import { Collapsible } from '../ui/collapsible';
import { Input } from '../ui/input';
import { LabelWithTooltip } from '../ui/label-with-tooltip';
import { AlertCircle } from 'lucide-react';
import type { AgentConfig, ContributorConfig } from '@dexto/core';
interface FormEditorProps {
config: AgentConfig;
onChange: (config: AgentConfig) => void;
errors?: Record<string, string>;
}
type SectionKey = 'basic' | 'llm' | 'systemPrompt' | 'mcpServers' | 'storage' | 'toolConfirmation';
export default function FormEditor({ config, onChange, errors = {} }: FormEditorProps) {
// Convert systemPrompt to contributors format for the UI
const systemPromptValue = (() => {
if (!config.systemPrompt) {
return { contributors: [] };
}
if (typeof config.systemPrompt === 'string') {
// Convert string to contributors array
return {
contributors: [
{
id: 'primary',
type: 'static' as const,
priority: 0,
enabled: true,
content: config.systemPrompt,
},
],
};
}
// Already in object format with contributors - ensure contributors array exists
return {
contributors: config.systemPrompt.contributors || [],
};
})();
// Track which sections are open
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
basic: true,
llm: false,
systemPrompt: false,
mcpServers: false,
storage: false,
toolConfirmation: false,
});
// Map errors to sections
const sectionErrors = mapErrorsToSections(errors);
// Auto-expand sections with errors
useEffect(() => {
// Compute derived value inside effect to avoid stale closures
const derivedSectionErrors = mapErrorsToSections(errors);
const sectionsWithErrors = Object.keys(derivedSectionErrors).filter(
(section) => derivedSectionErrors[section as SectionKey].length > 0
) as SectionKey[];
if (sectionsWithErrors.length > 0) {
setOpenSections((prev) => {
const updated = { ...prev };
sectionsWithErrors.forEach((section) => {
updated[section] = true;
});
return updated;
});
}
}, [errors]);
const toggleSection = (section: SectionKey) => {
setOpenSections((prev) => ({
...prev,
[section]: !prev[section],
}));
};
// Handle section updates
const updateLLM = (llm: AgentConfig['llm']) => {
onChange({ ...config, llm });
};
const updateSystemPrompt = (value: { contributors: ContributorConfig[] }) => {
onChange({ ...config, systemPrompt: value });
};
const updateMcpServers = (mcpServers: AgentConfig['mcpServers']) => {
onChange({ ...config, mcpServers });
};
const updateStorage = (storage: AgentConfig['storage']) => {
onChange({ ...config, storage });
};
const updateToolConfirmation = (toolConfirmation: AgentConfig['toolConfirmation']) => {
onChange({ ...config, toolConfirmation });
};
// Check if config has advanced features that aren't supported in form mode
const hasAdvancedFeatures = checkForAdvancedFeatures(config);
return (
<div className="flex flex-col h-full overflow-auto">
{/* Advanced Features Warning */}
{hasAdvancedFeatures && (
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-yellow-600 dark:text-yellow-500">
Advanced Configuration Detected
</p>
<p className="text-xs text-yellow-600/80 dark:text-yellow-500/80 mt-1">
Some advanced features may not be editable in form mode. Switch to
YAML editor for full control.
</p>
</div>
</div>
</div>
)}
{/* Form Sections */}
<div className="flex-1 p-4 space-y-4">
{/* Basic Info Section */}
<Collapsible
title="Basic Information"
open={openSections.basic}
onOpenChange={() => toggleSection('basic')}
errorCount={sectionErrors.basic.length}
sectionErrors={sectionErrors.basic}
>
<div className="space-y-2">
<LabelWithTooltip
htmlFor="agent-greeting"
tooltip="The initial message shown to users when they start a conversation"
>
Greeting Message
</LabelWithTooltip>
<Input
id="agent-greeting"
value={config.greeting || ''}
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
placeholder="Hello! How can I help you today?"
aria-invalid={!!errors.greeting}
/>
{errors.greeting && (
<p className="text-xs text-destructive mt-1">{errors.greeting}</p>
)}
</div>
</Collapsible>
{/* LLM Configuration */}
<LLMConfigSection
value={config.llm}
onChange={updateLLM}
errors={errors}
open={openSections.llm}
onOpenChange={() => toggleSection('llm')}
errorCount={sectionErrors.llm.length}
sectionErrors={sectionErrors.llm}
/>
{/* System Prompt */}
<SystemPromptSection
value={systemPromptValue}
onChange={updateSystemPrompt}
errors={errors}
open={openSections.systemPrompt}
onOpenChange={() => toggleSection('systemPrompt')}
errorCount={sectionErrors.systemPrompt.length}
sectionErrors={sectionErrors.systemPrompt}
/>
{/* MCP Servers */}
<McpServersSection
value={config.mcpServers || {}}
onChange={updateMcpServers}
errors={errors}
open={openSections.mcpServers}
onOpenChange={() => toggleSection('mcpServers')}
errorCount={sectionErrors.mcpServers.length}
sectionErrors={sectionErrors.mcpServers}
/>
{/* Storage Configuration */}
<StorageSection
value={
config.storage || {
cache: { type: 'in-memory' },
database: { type: 'in-memory' },
blob: { type: 'local', storePath: '/tmp/dexto-blobs' },
}
}
onChange={updateStorage}
errors={errors}
open={openSections.storage}
onOpenChange={() => toggleSection('storage')}
errorCount={sectionErrors.storage.length}
sectionErrors={sectionErrors.storage}
/>
{/* Tool Confirmation */}
<ToolConfirmationSection
value={config.toolConfirmation || {}}
onChange={updateToolConfirmation}
errors={errors}
open={openSections.toolConfirmation}
onOpenChange={() => toggleSection('toolConfirmation')}
errorCount={sectionErrors.toolConfirmation.length}
sectionErrors={sectionErrors.toolConfirmation}
/>
</div>
</div>
);
}
/**
* Check if config has advanced features that aren't well-supported in form mode
*/
function checkForAdvancedFeatures(config: AgentConfig): boolean {
// System prompt is now fully supported in form mode via contributors
// Check for session config customization
if (config.sessions && Object.keys(config.sessions).length > 0) {
return true;
}
// Check for internal tools customization
if (config.internalTools) {
return true;
}
return false;
}
/**
* Map error paths to form sections
*/
function mapErrorsToSections(errors: Record<string, string>): Record<SectionKey, string[]> {
const sectionErrors: Record<SectionKey, string[]> = {
basic: [],
llm: [],
systemPrompt: [],
mcpServers: [],
storage: [],
toolConfirmation: [],
};
Object.entries(errors).forEach(([path, message]) => {
if (path === 'greeting') {
sectionErrors.basic.push(message);
} else if (path.startsWith('llm.')) {
sectionErrors.llm.push(message);
} else if (path.startsWith('systemPrompt')) {
sectionErrors.systemPrompt.push(message);
} else if (path.startsWith('mcpServers')) {
sectionErrors.mcpServers.push(message);
} else if (path.startsWith('storage.')) {
sectionErrors.storage.push(message);
} else if (path.startsWith('toolConfirmation.')) {
sectionErrors.toolConfirmation.push(message);
}
});
return sectionErrors;
}

View File

@@ -0,0 +1,766 @@
/**
* FormEditorTabs - Clean tabbed form editor for agent configuration
*
* Design follows session/server panel patterns:
* - Minimal borders, spacing-based hierarchy
* - Section headers as uppercase labels
* - shadcn Select components
*/
import React, { useState, useMemo } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Button } from '../ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import {
Settings,
Brain,
Wrench,
Eye,
EyeOff,
Plus,
Trash2,
Info,
Loader2,
ChevronRight,
ChevronDown,
Server,
} from 'lucide-react';
import type { AgentConfig, ContributorConfig } from '@dexto/core';
import { LLM_PROVIDERS, MCP_SERVER_TYPES } from '@dexto/core';
import { cn } from '@/lib/utils';
import { useDiscovery } from '../hooks/useDiscovery';
import { useLLMCatalog } from '../hooks/useLLM';
// Providers that support custom baseURL
const BASE_URL_PROVIDERS = ['openai-compatible', 'litellm'];
interface FormEditorTabsProps {
config: AgentConfig;
onChange: (config: AgentConfig) => void;
errors?: Record<string, string>;
}
type TabValue = 'model' | 'behavior' | 'tools';
export default function FormEditorTabs({ config, onChange, errors = {} }: FormEditorTabsProps) {
const [activeTab, setActiveTab] = useState<TabValue>('model');
// Count errors per tab
const modelErrors = Object.keys(errors).filter(
(k) => k.startsWith('llm.') || k === 'greeting'
).length;
const behaviorErrors = Object.keys(errors).filter((k) => k.startsWith('systemPrompt')).length;
const toolsErrors = Object.keys(errors).filter(
(k) =>
k.startsWith('mcpServers') ||
k.startsWith('internalTools') ||
k.startsWith('customTools')
).length;
return (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="flex flex-col h-full"
>
<TabsList className="shrink-0">
<TabsTrigger
value="model"
icon={<Settings className="h-3.5 w-3.5" />}
badge={modelErrors > 0 ? <ErrorBadge count={modelErrors} /> : undefined}
>
Model
</TabsTrigger>
<TabsTrigger
value="behavior"
icon={<Brain className="h-3.5 w-3.5" />}
badge={behaviorErrors > 0 ? <ErrorBadge count={behaviorErrors} /> : undefined}
>
Behavior
</TabsTrigger>
<TabsTrigger
value="tools"
icon={<Wrench className="h-3.5 w-3.5" />}
badge={toolsErrors > 0 ? <ErrorBadge count={toolsErrors} /> : undefined}
>
Tools
</TabsTrigger>
</TabsList>
<TabsContent value="model" className="flex-1 overflow-y-auto">
<ModelTab config={config} onChange={onChange} errors={errors} />
</TabsContent>
<TabsContent value="behavior" className="flex-1 overflow-y-auto">
<BehaviorTab config={config} onChange={onChange} errors={errors} />
</TabsContent>
<TabsContent value="tools" className="flex-1 overflow-y-auto">
<ToolsTab config={config} onChange={onChange} errors={errors} />
</TabsContent>
</Tabs>
);
}
function ErrorBadge({ count }: { count: number }) {
return (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium bg-destructive text-destructive-foreground rounded-full">
{count}
</span>
);
}
// ============================================================================
// MODEL TAB - LLM Configuration
// ============================================================================
interface TabProps {
config: AgentConfig;
onChange: (config: AgentConfig) => void;
errors: Record<string, string>;
}
function ModelTab({ config, onChange, errors }: TabProps) {
const [showApiKey, setShowApiKey] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const { data: catalogData, isLoading: catalogLoading } = useLLMCatalog({ mode: 'grouped' });
const currentProvider = config.llm?.provider || '';
const supportsBaseURL = BASE_URL_PROVIDERS.includes(currentProvider);
const providerModels = useMemo(() => {
if (!catalogData || !('providers' in catalogData) || !currentProvider) return [];
const providerData =
catalogData.providers[currentProvider as keyof typeof catalogData.providers];
if (!providerData?.models) return [];
return providerData.models.map((m) => ({
id: m.name,
displayName: m.displayName || m.name,
}));
}, [catalogData, currentProvider]);
const updateLLM = (updates: Partial<NonNullable<AgentConfig['llm']>>) => {
onChange({
...config,
llm: { ...config.llm, ...updates } as AgentConfig['llm'],
});
};
return (
<div className="p-5 space-y-8">
{/* Language Model Section */}
<Section title="Language Model">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field label="Provider" required error={errors['llm.provider']}>
<Select
value={currentProvider}
onValueChange={(value) => {
updateLLM({
provider: value as never,
model: '', // Reset model when switching providers
...(value &&
!BASE_URL_PROVIDERS.includes(value) && {
baseURL: undefined,
}),
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{p.charAt(0).toUpperCase() +
p.slice(1).replace(/-/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Model" required error={errors['llm.model']}>
{catalogLoading ? (
<div className="flex items-center h-9 px-3 text-sm text-muted-foreground border border-input rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</div>
) : providerModels.length > 0 ? (
<Select
value={config.llm?.model || ''}
onValueChange={(value) => updateLLM({ model: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
{providerModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.llm?.model || ''}
onChange={(e) => updateLLM({ model: e.target.value })}
placeholder={
currentProvider
? 'Enter model name'
: 'Select provider first'
}
aria-invalid={!!errors['llm.model']}
/>
)}
</Field>
</div>
<Field label="API Key" hint="Use $ENV_VAR for environment variables">
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
value={config.llm?.apiKey ?? ''}
onChange={(e) => updateLLM({ apiKey: e.target.value })}
placeholder="$ANTHROPIC_API_KEY"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</Field>
{/* Base URL - Only for OpenAI-compatible providers */}
{supportsBaseURL && (
<Field
label="Base URL"
required
hint="Custom API endpoint for this provider"
error={errors['llm.baseURL']}
>
<Input
value={config.llm?.baseURL ?? ''}
onChange={(e) =>
updateLLM({ baseURL: e.target.value || undefined })
}
placeholder="https://api.example.com/v1"
/>
</Field>
)}
{/* Advanced Settings */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
>
{showAdvanced ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-medium">Advanced Settings</span>
</button>
{showAdvanced && (
<div className="ml-6 space-y-4 pl-4 border-l-2 border-border/30">
<Field
label="Max Output Tokens"
hint="Maximum tokens for model responses"
>
<Input
type="number"
value={config.llm?.maxOutputTokens ?? ''}
onChange={(e) =>
updateLLM({
maxOutputTokens: e.target.value
? parseInt(e.target.value, 10)
: undefined,
})
}
placeholder="4096"
min="1"
/>
</Field>
</div>
)}
</div>
</Section>
{/* Greeting Section */}
<Section title="Greeting">
<Field hint="Initial message shown to users">
<Input
value={config.greeting || ''}
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
placeholder="Hello! How can I help you today?"
/>
</Field>
</Section>
</div>
);
}
// ============================================================================
// BEHAVIOR TAB - System Prompt
// ============================================================================
function BehaviorTab({ config, onChange, errors }: TabProps) {
const getPromptContent = (): string => {
if (!config.systemPrompt) return '';
if (typeof config.systemPrompt === 'string') return config.systemPrompt;
const primary = config.systemPrompt.contributors?.find((c) => c.type === 'static');
return primary && 'content' in primary ? primary.content : '';
};
const updatePromptContent = (content: string) => {
if (!config.systemPrompt || typeof config.systemPrompt === 'string') {
onChange({
...config,
systemPrompt: {
contributors: [
{ id: 'primary', type: 'static', priority: 0, enabled: true, content },
],
},
});
} else {
const contributors = [...(config.systemPrompt.contributors || [])];
const primaryIdx = contributors.findIndex((c) => c.id === 'primary');
if (primaryIdx >= 0) {
contributors[primaryIdx] = {
...contributors[primaryIdx],
content,
} as ContributorConfig;
} else {
contributors.unshift({
id: 'primary',
type: 'static',
priority: 0,
enabled: true,
content,
});
}
onChange({ ...config, systemPrompt: { contributors } });
}
};
const hasMultipleContributors =
config.systemPrompt &&
typeof config.systemPrompt === 'object' &&
config.systemPrompt.contributors &&
config.systemPrompt.contributors.length > 1;
return (
<div className="p-5 h-full flex flex-col">
<Section title="System Prompt" className="flex-1 flex flex-col">
<Field error={errors.systemPrompt} className="flex-1 flex flex-col">
<Textarea
value={getPromptContent()}
onChange={(e) => updatePromptContent(e.target.value)}
placeholder="You are a helpful assistant..."
className="font-mono text-sm resize-none flex-1 min-h-[400px]"
/>
</Field>
{hasMultipleContributors && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5 mt-3">
<Info className="h-3.5 w-3.5" />
This agent has multiple prompt contributors. Edit in YAML for full control.
</p>
)}
</Section>
</div>
);
}
// ============================================================================
// TOOLS TAB - Internal Tools, Custom Tools, MCP Servers
// ============================================================================
function ToolsTab({ config, onChange, errors }: TabProps) {
const { data: discovery, isLoading: discoveryLoading } = useDiscovery();
const servers = Object.entries(config.mcpServers || {});
const enabledInternalTools = (config.internalTools || []) as string[];
const toggleInternalTool = (toolName: string) => {
const next = enabledInternalTools.includes(toolName)
? enabledInternalTools.filter((t) => t !== toolName)
: [...enabledInternalTools, toolName];
onChange({ ...config, internalTools: next as typeof config.internalTools });
};
const enabledCustomTools = (config.customTools || []).map((t) => t.type);
const toggleCustomTool = (toolType: string) => {
const current = config.customTools || [];
const isEnabled = current.some((t) => t.type === toolType);
const next = isEnabled
? current.filter((t) => t.type !== toolType)
: [...current, { type: toolType }];
onChange({ ...config, customTools: next });
};
const toolPolicies = config.toolConfirmation?.toolPolicies || {
alwaysAllow: [],
alwaysDeny: [],
};
const alwaysAllowList = toolPolicies.alwaysAllow || [];
const isToolAutoApproved = (qualifiedName: string) => alwaysAllowList.includes(qualifiedName);
const toggleToolAutoApprove = (qualifiedName: string) => {
const newAlwaysAllow = isToolAutoApproved(qualifiedName)
? alwaysAllowList.filter((t) => t !== qualifiedName)
: [...alwaysAllowList, qualifiedName];
onChange({
...config,
toolConfirmation: {
...config.toolConfirmation,
toolPolicies: {
...toolPolicies,
alwaysAllow: newAlwaysAllow,
},
},
});
};
const addServer = () => {
const newName = `server-${servers.length + 1}`;
onChange({
...config,
mcpServers: {
...config.mcpServers,
[newName]: { type: 'stdio', command: '', connectionMode: 'lenient' },
},
});
};
const removeServer = (name: string) => {
const newServers = { ...config.mcpServers };
delete newServers[name];
onChange({ ...config, mcpServers: newServers });
};
const updateServer = (
name: string,
updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>
) => {
const server = config.mcpServers?.[name];
if (!server) return;
onChange({
...config,
mcpServers: {
...config.mcpServers,
[name]: { ...server, ...updates } as NonNullable<AgentConfig['mcpServers']>[string],
},
});
};
const internalToolsCount = discovery?.internalTools?.length || 0;
const customToolsCount = discovery?.customTools?.length || 0;
return (
<div className="p-5 space-y-8">
{/* Internal Tools */}
<Section title="Internal Tools" description="Built-in capabilities">
{discoveryLoading ? (
<div className="flex items-center gap-2 py-6 justify-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading tools...
</div>
) : internalToolsCount > 0 ? (
<div className="space-y-1">
{discovery!.internalTools.map((tool) => {
const isEnabled = enabledInternalTools.includes(tool.name);
const qualifiedName = `internal--${tool.name}`;
const isAutoApproved = isToolAutoApproved(qualifiedName);
return (
<ToolRow
key={tool.name}
name={tool.name}
description={tool.description}
isEnabled={isEnabled}
isAutoApproved={isAutoApproved}
onToggleEnabled={() => toggleInternalTool(tool.name)}
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
/>
);
})}
<p className="text-xs text-muted-foreground/60 pt-3">
{enabledInternalTools.length} of {internalToolsCount} enabled
</p>
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
No internal tools available
</p>
)}
</Section>
{/* Custom Tools */}
{customToolsCount > 0 && (
<Section title="Custom Tools" description="Additional providers">
<div className="space-y-1">
{discovery!.customTools.map((tool) => {
const isEnabled = enabledCustomTools.includes(tool.type);
const qualifiedName = `custom--${tool.type}`;
const isAutoApproved = isToolAutoApproved(qualifiedName);
const displayName = tool.metadata?.displayName || tool.type;
const description = tool.metadata?.description;
return (
<ToolRow
key={tool.type}
name={displayName}
description={description}
isEnabled={isEnabled}
isAutoApproved={isAutoApproved}
onToggleEnabled={() => toggleCustomTool(tool.type)}
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
/>
);
})}
<p className="text-xs text-muted-foreground/60 pt-3">
{enabledCustomTools.length} of {customToolsCount} enabled
</p>
</div>
</Section>
)}
{/* MCP Servers */}
<Section title="MCP Servers" description="External tools via Model Context Protocol">
{servers.length === 0 ? (
<div className="py-8 text-center">
<Server className="h-8 w-8 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm text-muted-foreground mb-4">No servers configured</p>
<Button onClick={addServer} variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1.5" />
Add Server
</Button>
</div>
) : (
<div className="space-y-3">
{servers.map(([name, server]) => (
<ServerCard
key={name}
name={name}
server={server}
onUpdate={(updates) => updateServer(name, updates)}
onRemove={() => removeServer(name)}
errors={errors}
/>
))}
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-1.5" />
Add Server
</Button>
</div>
)}
</Section>
</div>
);
}
function ToolRow({
name,
description,
isEnabled,
isAutoApproved,
onToggleEnabled,
onToggleAutoApprove,
}: {
name: string;
description?: string;
isEnabled: boolean;
isAutoApproved: boolean;
onToggleEnabled: () => void;
onToggleAutoApprove: () => void;
}) {
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isEnabled ? 'bg-muted/40' : 'hover:bg-muted/20'
)}
>
<input
type="checkbox"
checked={isEnabled}
onChange={onToggleEnabled}
className="h-4 w-4 rounded cursor-pointer shrink-0"
/>
<div className="flex-1 min-w-0">
<span className={cn('text-sm font-medium', !isEnabled && 'text-muted-foreground')}>
{name}
</span>
{description && (
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
{description}
</p>
)}
</div>
{isEnabled && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer shrink-0 px-2 py-1 rounded hover:bg-muted/50 transition-colors">
<input
type="checkbox"
checked={isAutoApproved}
onChange={onToggleAutoApprove}
className="h-3 w-3 rounded"
/>
<span>Auto-approve</span>
</label>
)}
</div>
);
}
function ServerCard({
name,
server,
onUpdate,
onRemove,
errors,
}: {
name: string;
server: NonNullable<AgentConfig['mcpServers']>[string];
onUpdate: (updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>) => void;
onRemove: () => void;
errors: Record<string, string>;
}) {
const isStdio = server.type === 'stdio';
return (
<div className="group p-4 rounded-lg bg-muted/30 hover:bg-muted/40 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">{name}</span>
<Select
value={server.type}
onValueChange={(type: 'stdio' | 'sse' | 'http') => {
if (type === 'stdio') {
onUpdate({ type: 'stdio', command: '' } as never);
} else {
onUpdate({ type, url: '' } as never);
}
}}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MCP_SERVER_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isStdio ? (
<Input
value={'command' in server ? server.command : ''}
onChange={(e) => onUpdate({ command: e.target.value } as never)}
placeholder="npx -y @modelcontextprotocol/server-filesystem"
className="text-sm font-mono"
aria-invalid={!!errors[`mcpServers.${name}.command`]}
/>
) : (
<Input
value={'url' in server ? server.url : ''}
onChange={(e) => onUpdate({ url: e.target.value } as never)}
placeholder="https://mcp.example.com/sse"
className="text-sm"
aria-invalid={!!errors[`mcpServers.${name}.url`]}
/>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
);
}
// ============================================================================
// SHARED COMPONENTS
// ============================================================================
function Section({
title,
description,
className,
children,
}: {
title: string;
description?: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('rounded-xl bg-muted/20 p-5', className)}>
<div className="mb-4">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{title}
</h3>
{description && (
<p className="text-xs text-muted-foreground/60 mt-0.5">{description}</p>
)}
</div>
{children}
</div>
);
}
function Field({
label,
required,
hint,
error,
className,
children,
}: {
label?: string;
required?: boolean;
hint?: string;
error?: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{children}
{hint && !error && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5">{hint}</p>
)}
{error && <p className="text-xs text-destructive mt-1.5">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import FormEditorTabs from './FormEditorTabs';
import type { AgentConfig } from '@dexto/core';
interface FormEditorViewProps {
config: AgentConfig;
onChange: (config: AgentConfig) => void;
errors?: Record<string, string>;
}
/**
* FormEditorView - Pure form editor wrapper with tabbed interface
*
* This component wraps FormEditorTabs and provides a clean interface.
* It doesn't handle YAML conversion or loading/saving - that's the parent's job.
*
* Reusable in both edit and create flows.
*/
export default function FormEditorView({ config, onChange, errors = {} }: FormEditorViewProps) {
return (
<div className="flex flex-col h-full">
<FormEditorTabs config={config} onChange={onChange} errors={errors} />
</div>
);
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import AgentConfigEditor from './AgentConfigEditor';
import ConfigValidationStatus from './ConfigValidationStatus';
import type { editor } from 'monaco-editor';
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
interface YAMLEditorViewProps {
value: string;
onChange: (value: string) => void;
onValidate?: (markers: editor.IMarker[]) => void;
isValidating?: boolean;
isValid?: boolean;
errors?: ValidationError[];
warnings?: ValidationWarning[];
hasUnsavedChanges?: boolean;
}
/**
* YAMLEditorView - Pure YAML editor with validation display
*
* This component is responsible for rendering the Monaco YAML editor
* and the validation status bar. It doesn't handle loading/saving -
* that's the parent's job.
*
* Reusable in both edit and create flows.
*/
export default function YAMLEditorView({
value,
onChange,
onValidate,
isValidating = false,
isValid = true,
errors = [],
warnings = [],
hasUnsavedChanges = false,
}: YAMLEditorViewProps) {
return (
<div className="flex flex-col h-full">
{/* Editor */}
<div className="flex-1 overflow-hidden">
<AgentConfigEditor
value={value}
onChange={onChange}
onValidate={onValidate}
height="100%"
/>
</div>
{/* Validation Status */}
<ConfigValidationStatus
isValidating={isValidating}
isValid={isValid}
errors={errors}
warnings={warnings}
hasUnsavedChanges={hasUnsavedChanges}
/>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import React, { useState } from 'react';
import { Input } from '../../ui/input';
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
import { Collapsible } from '../../ui/collapsible';
import { Eye, EyeOff } from 'lucide-react';
import { LLM_PROVIDERS, isReasoningCapableModel, type AgentConfig } from '@dexto/core';
type LLMConfig = AgentConfig['llm'];
interface LLMConfigSectionProps {
value: LLMConfig;
onChange: (value: LLMConfig) => void;
errors?: Record<string, string>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
}
export function LLMConfigSection({
value,
onChange,
errors = {},
open,
onOpenChange,
errorCount = 0,
sectionErrors = [],
}: LLMConfigSectionProps) {
const [showApiKey, setShowApiKey] = useState(false);
const handleChange = (field: keyof LLMConfig, newValue: string | number | undefined) => {
onChange({ ...value, [field]: newValue } as LLMConfig);
};
return (
<Collapsible
title="LLM Configuration"
defaultOpen={true}
open={open}
onOpenChange={onOpenChange}
errorCount={errorCount}
sectionErrors={sectionErrors}
>
<div className="space-y-4">
{/* Provider */}
<div>
<LabelWithTooltip
htmlFor="provider"
tooltip="The LLM provider to use (e.g., OpenAI, Anthropic)"
>
Provider *
</LabelWithTooltip>
<select
id="provider"
value={value.provider || ''}
onChange={(e) => handleChange('provider', e.target.value)}
aria-invalid={!!errors['llm.provider']}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
>
<option value="">Select provider...</option>
{LLM_PROVIDERS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{errors['llm.provider'] && (
<p className="text-xs text-destructive mt-1">{errors['llm.provider']}</p>
)}
</div>
{/* Model */}
<div>
<LabelWithTooltip
htmlFor="model"
tooltip="The specific model identifier (e.g., gpt-5, claude-sonnet-4-5-20250929)"
>
Model *
</LabelWithTooltip>
<Input
id="model"
value={value.model || ''}
onChange={(e) => handleChange('model', e.target.value)}
placeholder="e.g., gpt-5, claude-sonnet-4-5-20250929"
aria-invalid={!!errors['llm.model']}
/>
{errors['llm.model'] && (
<p className="text-xs text-destructive mt-1">{errors['llm.model']}</p>
)}
</div>
{/* API Key */}
<div>
<LabelWithTooltip
htmlFor="apiKey"
tooltip="Use $ENV_VAR for environment variables or enter the API key directly"
>
API Key *
</LabelWithTooltip>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
value={value.apiKey ?? ''}
onChange={(e) => handleChange('apiKey', e.target.value)}
placeholder="$OPENAI_API_KEY or direct value"
aria-invalid={!!errors['llm.apiKey']}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded transition-colors"
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
{errors['llm.apiKey'] && (
<p className="text-xs text-destructive mt-1">{errors['llm.apiKey']}</p>
)}
</div>
{/* Max Iterations */}
<div>
<LabelWithTooltip
htmlFor="maxIterations"
tooltip="Maximum number of agent reasoning iterations per turn"
>
Max Iterations
</LabelWithTooltip>
<Input
id="maxIterations"
type="number"
value={value.maxIterations !== undefined ? value.maxIterations : ''}
onChange={(e) => {
const val = e.target.value;
if (val === '') {
handleChange('maxIterations', undefined);
} else {
const num = parseInt(val, 10);
if (!isNaN(num)) {
handleChange('maxIterations', num);
}
}
}}
min="1"
placeholder="50"
aria-invalid={!!errors['llm.maxIterations']}
/>
{errors['llm.maxIterations'] && (
<p className="text-xs text-destructive mt-1">
{errors['llm.maxIterations']}
</p>
)}
</div>
{/* Base URL */}
<div>
<LabelWithTooltip
htmlFor="baseURL"
tooltip="Custom base URL for the LLM provider (optional, for proxies or custom endpoints)"
>
Base URL
</LabelWithTooltip>
<Input
id="baseURL"
value={value.baseURL || ''}
onChange={(e) => handleChange('baseURL', e.target.value || undefined)}
placeholder="https://api.openai.com/v1"
aria-invalid={!!errors['llm.baseURL']}
/>
{errors['llm.baseURL'] && (
<p className="text-xs text-destructive mt-1">{errors['llm.baseURL']}</p>
)}
</div>
{/* Temperature */}
<div>
<LabelWithTooltip
htmlFor="temperature"
tooltip="Controls randomness in responses (0.0 = deterministic, 1.0 = creative)"
>
Temperature
</LabelWithTooltip>
<Input
id="temperature"
type="number"
value={value.temperature !== undefined ? value.temperature : ''}
onChange={(e) =>
handleChange(
'temperature',
e.target.value ? parseFloat(e.target.value) : undefined
)
}
min="0"
max="1"
step="0.1"
placeholder="0.0 - 1.0"
aria-invalid={!!errors['llm.temperature']}
/>
{errors['llm.temperature'] && (
<p className="text-xs text-destructive mt-1">{errors['llm.temperature']}</p>
)}
</div>
{/* Max Input/Output Tokens */}
<div className="grid grid-cols-2 gap-4">
<div>
<LabelWithTooltip
htmlFor="maxInputTokens"
tooltip="Maximum input tokens to send to the model. If not specified, defaults to model's limit from registry, or 128,000 tokens for custom endpoints"
>
Max Input Tokens
</LabelWithTooltip>
<Input
id="maxInputTokens"
type="number"
value={value.maxInputTokens || ''}
onChange={(e) =>
handleChange(
'maxInputTokens',
e.target.value ? parseInt(e.target.value, 10) : undefined
)
}
min="1"
placeholder="Auto (128k fallback)"
aria-invalid={!!errors['llm.maxInputTokens']}
/>
{errors['llm.maxInputTokens'] && (
<p className="text-xs text-destructive mt-1">
{errors['llm.maxInputTokens']}
</p>
)}
</div>
<div>
<LabelWithTooltip
htmlFor="maxOutputTokens"
tooltip="Maximum output tokens the model can generate. If not specified, uses provider's default (typically 4,096 tokens)"
>
Max Output Tokens
</LabelWithTooltip>
<Input
id="maxOutputTokens"
type="number"
value={value.maxOutputTokens || ''}
onChange={(e) =>
handleChange(
'maxOutputTokens',
e.target.value ? parseInt(e.target.value, 10) : undefined
)
}
min="1"
placeholder="Auto (provider default)"
aria-invalid={!!errors['llm.maxOutputTokens']}
/>
{errors['llm.maxOutputTokens'] && (
<p className="text-xs text-destructive mt-1">
{errors['llm.maxOutputTokens']}
</p>
)}
</div>
</div>
{/* Provider-Specific Options */}
{/* Reasoning Effort - Only for models that support it (o1, o3, codex, gpt-5.x) */}
{value.model && isReasoningCapableModel(value.model) && (
<div>
<LabelWithTooltip
htmlFor="reasoningEffort"
tooltip="Controls reasoning depth for OpenAI models (o1, o3, codex, gpt-5.x). Higher = more thorough but slower/costlier. 'medium' is recommended for most tasks."
>
Reasoning Effort
</LabelWithTooltip>
<select
id="reasoningEffort"
value={value.reasoningEffort || ''}
onChange={(e) =>
handleChange('reasoningEffort', e.target.value || undefined)
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="">Auto (model default)</option>
<option value="none">None - No reasoning</option>
<option value="minimal">Minimal - Barely any reasoning</option>
<option value="low">Low - Light reasoning</option>
<option value="medium">Medium - Balanced (recommended)</option>
<option value="high">High - Thorough reasoning</option>
<option value="xhigh">Extra High - Maximum quality</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Only applies to reasoning models (o1, o3, codex, gpt-5.x)
</p>
</div>
)}
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,538 @@
import React, { useState } from 'react';
import { Input } from '../../ui/input';
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
import { Button } from '../../ui/button';
import { Collapsible } from '../../ui/collapsible';
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import type { AgentConfig } from '@dexto/core';
import { MCP_SERVER_TYPES, MCP_CONNECTION_MODES, DEFAULT_MCP_CONNECTION_MODE } from '@dexto/core';
type McpServersConfig = NonNullable<AgentConfig['mcpServers']>;
interface McpServersSectionProps {
value: McpServersConfig;
onChange: (value: McpServersConfig) => void;
errors?: Record<string, string>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
}
export function McpServersSection({
value,
onChange,
errors = {},
open,
onOpenChange,
errorCount = 0,
sectionErrors = [],
}: McpServersSectionProps) {
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Local state for text fields that need special parsing (args, env, headers)
// Key is "serverName:fieldName", value is the raw string being edited
const [editingFields, setEditingFields] = useState<Record<string, string>>({});
const servers = Object.entries(value || {});
const toggleServer = (name: string) => {
setExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const addServer = () => {
const newName = `server-${Object.keys(value || {}).length + 1}`;
onChange({
...value,
[newName]: {
type: 'stdio',
command: '',
connectionMode: 'strict',
},
});
setExpandedServers((prev) => new Set(prev).add(newName));
};
const removeServer = (name: string) => {
const newValue = { ...value };
delete newValue[name];
onChange(newValue);
setExpandedServers((prev) => {
const next = new Set(prev);
next.delete(name);
return next;
});
};
const updateServer = (
oldName: string,
updates: Partial<Record<string, unknown> & { name?: string }>
) => {
const server = value[oldName];
// Extract name from updates if present (it's not part of the server config, just used for the key)
const { name: newName, ...serverUpdates } = updates;
const newServer = { ...server, ...serverUpdates } as McpServersConfig[string];
// If name changed via updates, handle the name change
if (newName && typeof newName === 'string' && newName !== oldName) {
// Guard against collision: prevent overwriting an existing server
if (value[newName]) {
// TODO: Surface a user-facing error via onChange/errors map or toast notification
return; // No-op to avoid overwriting an existing server
}
const newValue = { ...value };
delete newValue[oldName];
newValue[newName] = newServer;
onChange(newValue);
// Update expanded state
setExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(oldName)) {
next.delete(oldName);
next.add(newName);
}
return next;
});
} else {
onChange({ ...value, [oldName]: newServer });
}
};
// Get the current value for a field (either from editing state or from config)
const getFieldValue = (serverName: string, fieldName: string, fallback: string): string => {
const key = `${serverName}:${fieldName}`;
return editingFields[key] ?? fallback;
};
// Update local editing state while typing
const setFieldValue = (serverName: string, fieldName: string, value: string) => {
const key = `${serverName}:${fieldName}`;
setEditingFields((prev) => ({ ...prev, [key]: value }));
};
// Clear editing state for a field
const clearFieldValue = (serverName: string, fieldName: string) => {
const key = `${serverName}:${fieldName}`;
setEditingFields((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
// Parse and commit args on blur
const commitArgs = (serverName: string, argsString: string) => {
clearFieldValue(serverName, 'args');
if (!argsString.trim()) {
updateServer(serverName, { args: undefined });
return;
}
const args = argsString
.split(',')
.map((arg) => arg.trim())
.filter(Boolean);
updateServer(serverName, { args: args.length > 0 ? args : undefined });
};
// Parse and commit env on blur
const commitEnv = (serverName: string, envString: string) => {
clearFieldValue(serverName, 'env');
if (!envString.trim()) {
updateServer(serverName, { env: undefined });
return;
}
const env: Record<string, string> = {};
envString
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
env[key.trim()] = valueParts.join('=').trim();
}
});
updateServer(serverName, { env: Object.keys(env).length > 0 ? env : undefined });
};
// Parse and commit headers on blur
const commitHeaders = (serverName: string, headersString: string) => {
clearFieldValue(serverName, 'headers');
if (!headersString.trim()) {
updateServer(serverName, { headers: undefined });
return;
}
const headers: Record<string, string> = {};
headersString
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
headers[key.trim()] = valueParts.join('=').trim();
}
});
updateServer(serverName, {
headers: Object.keys(headers).length > 0 ? headers : undefined,
});
};
return (
<Collapsible
title="MCP Servers"
defaultOpen={false}
open={open}
onOpenChange={onOpenChange}
errorCount={errorCount}
sectionErrors={sectionErrors}
>
<div className="space-y-4">
{servers.length === 0 ? (
<p className="text-sm text-muted-foreground">No MCP servers configured</p>
) : (
servers.map(([name, server]) => {
const isExpanded = expandedServers.has(name);
return (
<div
key={name}
className="border border-border rounded-lg overflow-hidden"
>
{/* Server Header */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<button
onClick={() => toggleServer(name)}
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<span className="font-medium text-sm">{name}</span>
{'command' in server && server.command && (
<span className="text-xs text-muted-foreground truncate">
({server.command})
</span>
)}
</button>
<Button
variant="ghost"
size="sm"
onClick={() => removeServer(name)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
{/* Server Details */}
{isExpanded && (
<div className="px-3 py-3 space-y-3">
{/* Server Name */}
<div>
<LabelWithTooltip
htmlFor={`server-name-${name}`}
tooltip="Unique identifier for this MCP server"
>
Server Name
</LabelWithTooltip>
<Input
id={`server-name-${name}`}
value={name}
onChange={(e) =>
updateServer(name, { name: e.target.value })
}
placeholder="e.g., filesystem"
/>
</div>
{/* Server Type */}
<div>
<LabelWithTooltip
htmlFor={`server-type-${name}`}
tooltip="MCP server connection type"
>
Connection Type *
</LabelWithTooltip>
<select
id={`server-type-${name}`}
value={server.type || 'stdio'}
onChange={(e) => {
const type = e.target.value as
| 'stdio'
| 'sse'
| 'http';
if (type === 'stdio') {
updateServer(name, {
type: 'stdio',
command: '',
args: undefined,
env: undefined,
});
} else {
updateServer(name, {
type,
url: '',
headers: undefined,
});
}
}}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MCP_SERVER_TYPES.map((type) => (
<option key={type} value={type}>
{type === 'stdio'
? 'Standard I/O (stdio)'
: type === 'sse'
? 'Server-Sent Events (SSE)'
: 'HTTP'}
</option>
))}
</select>
</div>
{/* stdio-specific fields */}
{server.type === 'stdio' && (
<>
{/* Command */}
<div>
<LabelWithTooltip
htmlFor={`server-command-${name}`}
tooltip="The command to execute (e.g., npx, node, python)"
>
Command *
</LabelWithTooltip>
<Input
id={`server-command-${name}`}
value={
'command' in server
? server.command
: ''
}
onChange={(e) =>
updateServer(name, {
command: e.target.value,
})
}
placeholder="e.g., npx, node, python"
aria-invalid={
!!errors[`mcpServers.${name}.command`]
}
/>
{errors[`mcpServers.${name}.command`] && (
<p className="text-xs text-destructive mt-1">
{errors[`mcpServers.${name}.command`]}
</p>
)}
</div>
{/* Arguments */}
<div>
<LabelWithTooltip
htmlFor={`server-args-${name}`}
tooltip="Command arguments, comma-separated"
>
Arguments
</LabelWithTooltip>
<Input
id={`server-args-${name}`}
value={getFieldValue(
name,
'args',
('args' in server && server.args
? server.args
: []
).join(', ')
)}
onChange={(e) =>
setFieldValue(
name,
'args',
e.target.value
)
}
onBlur={(e) =>
commitArgs(name, e.target.value)
}
placeholder="--port, 3000, --host, localhost"
className="font-mono"
/>
</div>
{/* Environment Variables */}
<div>
<LabelWithTooltip
htmlFor={`server-env-${name}`}
tooltip="Environment variables in KEY=value format, one per line"
>
Environment Variables
</LabelWithTooltip>
<textarea
id={`server-env-${name}`}
value={getFieldValue(
name,
'env',
Object.entries(
('env' in server && server.env) ||
{}
)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
)}
onChange={(e) =>
setFieldValue(
name,
'env',
e.target.value
)
}
onBlur={(e) =>
commitEnv(name, e.target.value)
}
placeholder={`API_KEY=$MY_API_KEY\nPORT=3000`}
rows={4}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
</div>
</>
)}
{/* sse/http-specific fields */}
{(server.type === 'sse' || server.type === 'http') && (
<>
{/* URL */}
<div>
<LabelWithTooltip
htmlFor={`server-url-${name}`}
tooltip="The URL endpoint for the MCP server"
>
URL *
</LabelWithTooltip>
<Input
id={`server-url-${name}`}
value={'url' in server ? server.url : ''}
onChange={(e) =>
updateServer(name, {
url: e.target.value,
})
}
placeholder="https://example.com/mcp"
aria-invalid={
!!errors[`mcpServers.${name}.url`]
}
/>
{errors[`mcpServers.${name}.url`] && (
<p className="text-xs text-destructive mt-1">
{errors[`mcpServers.${name}.url`]}
</p>
)}
</div>
{/* Headers */}
<div>
<LabelWithTooltip
htmlFor={`server-headers-${name}`}
tooltip="HTTP headers in KEY=value format, one per line"
>
Headers
</LabelWithTooltip>
<textarea
id={`server-headers-${name}`}
value={getFieldValue(
name,
'headers',
Object.entries(
('headers' in server &&
server.headers) ||
{}
)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
)}
onChange={(e) =>
setFieldValue(
name,
'headers',
e.target.value
)
}
onBlur={(e) =>
commitHeaders(name, e.target.value)
}
placeholder={`Authorization=Bearer token\nContent-Type=application/json`}
rows={4}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
</div>
</>
)}
{/* Connection Mode */}
<div>
<LabelWithTooltip
htmlFor={`server-mode-${name}`}
tooltip="Strict mode fails on any error; lenient mode continues despite errors"
>
Connection Mode
</LabelWithTooltip>
<select
id={`server-mode-${name}`}
value={
server.connectionMode ||
DEFAULT_MCP_CONNECTION_MODE
}
onChange={(e) =>
updateServer(name, {
connectionMode: e.target.value as
| 'strict'
| 'lenient',
})
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MCP_CONNECTION_MODES.map((mode) => (
<option key={mode} value={mode}>
{mode.charAt(0).toUpperCase() +
mode.slice(1)}
</option>
))}
</select>
</div>
</div>
)}
</div>
);
})
)}
{/* Add Server Button */}
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add MCP Server
</Button>
{errors.mcpServers && (
<p className="text-xs text-destructive mt-1">{errors.mcpServers}</p>
)}
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,183 @@
import React from 'react';
import { Input } from '../../ui/input';
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
import { Collapsible } from '../../ui/collapsible';
import type { AgentConfig, CacheType, DatabaseType } from '@dexto/core';
import { CACHE_TYPES, DATABASE_TYPES } from '@dexto/core';
type StorageConfig = NonNullable<AgentConfig['storage']>;
interface StorageSectionProps {
value: StorageConfig;
onChange: (value: StorageConfig) => void;
errors?: Record<string, string>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
}
export function StorageSection({
value,
onChange,
errors = {},
open,
onOpenChange,
errorCount = 0,
sectionErrors = [],
}: StorageSectionProps) {
const updateCache = (updates: Partial<Record<string, unknown>>) => {
onChange({
...value,
cache: { ...value.cache, ...updates } as StorageConfig['cache'],
});
};
const updateDatabase = (updates: Partial<Record<string, unknown>>) => {
onChange({
...value,
database: { ...value.database, ...updates } as StorageConfig['database'],
});
};
const showCacheUrl = value.cache.type === 'redis';
const showDatabaseUrl = value.database.type === 'sqlite' || value.database.type === 'postgres';
return (
<Collapsible
title="Storage Configuration"
defaultOpen={false}
open={open}
onOpenChange={onOpenChange}
errorCount={errorCount}
sectionErrors={sectionErrors}
>
<div className="space-y-6">
{/* Cache Configuration */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Cache</h4>
<div>
<LabelWithTooltip
htmlFor="cache-type"
tooltip="Storage backend for caching data (in-memory or Redis)"
>
Cache Type
</LabelWithTooltip>
<select
id="cache-type"
value={value.cache.type}
onChange={(e) => updateCache({ type: e.target.value as CacheType })}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{CACHE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{showCacheUrl && 'url' in value.cache && (
<div>
<LabelWithTooltip
htmlFor="cache-url"
tooltip="Redis connection URL (e.g., redis://localhost:6379)"
>
Redis URL
</LabelWithTooltip>
<Input
id="cache-url"
value={value.cache.url || ''}
onChange={(e) => updateCache({ url: e.target.value || undefined })}
placeholder="redis://localhost:6379"
aria-invalid={!!errors['storage.cache.url']}
/>
{errors['storage.cache.url'] && (
<p className="text-xs text-destructive mt-1">
{errors['storage.cache.url']}
</p>
)}
</div>
)}
</div>
{/* Database Configuration */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Database</h4>
<div>
<LabelWithTooltip
htmlFor="database-type"
tooltip="Storage backend for persistent data (in-memory, SQLite, or PostgreSQL)"
>
Database Type
</LabelWithTooltip>
<select
id="database-type"
value={value.database.type}
onChange={(e) =>
updateDatabase({ type: e.target.value as DatabaseType })
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{DATABASE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
{showDatabaseUrl && (
<div>
<LabelWithTooltip
htmlFor="database-url"
tooltip={
value.database.type === 'sqlite'
? 'File path for SQLite database'
: 'PostgreSQL connection string'
}
>
{value.database.type === 'sqlite'
? 'SQLite Path'
: 'PostgreSQL URL'}
</LabelWithTooltip>
<Input
id="database-url"
value={
('url' in value.database && value.database.url) ||
('path' in value.database && value.database.path) ||
''
}
onChange={(e) => {
if (value.database.type === 'sqlite') {
updateDatabase({ path: e.target.value || undefined });
} else {
updateDatabase({ url: e.target.value || undefined });
}
}}
placeholder={
value.database.type === 'sqlite'
? './dexto.db'
: 'postgresql://user:pass@localhost:5432/dexto'
}
aria-invalid={
!!(
errors['storage.database.url'] ||
errors['storage.database.path']
)
}
/>
{(errors['storage.database.url'] ||
errors['storage.database.path']) && (
<p className="text-xs text-destructive mt-1">
{errors['storage.database.url'] ||
errors['storage.database.path']}
</p>
)}
</div>
)}
</div>
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,582 @@
import React, { useState } from 'react';
import { Input } from '../../ui/input';
import { Textarea } from '../../ui/textarea';
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
import { Button } from '../../ui/button';
import { Collapsible } from '../../ui/collapsible';
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { PROMPT_GENERATOR_SOURCES } from '@dexto/core';
import type { ContributorConfig } from '@dexto/core';
// Component works with the object form of SystemPromptConfig (not the string form)
type SystemPromptConfigObject = {
contributors: ContributorConfig[];
};
interface SystemPromptSectionProps {
value: SystemPromptConfigObject;
onChange: (value: SystemPromptConfigObject) => void;
errors?: Record<string, string>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
}
export function SystemPromptSection({
value,
onChange,
errors = {},
open,
onOpenChange,
errorCount = 0,
sectionErrors = [],
}: SystemPromptSectionProps) {
const [expandedContributors, setExpandedContributors] = useState<Set<string>>(new Set());
// Local state for file paths (comma-separated editing)
const [editingFiles, setEditingFiles] = useState<Record<string, string>>({});
const contributors = value.contributors || [];
const toggleContributor = (id: string) => {
setExpandedContributors((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const addContributor = () => {
const newId = `contributor-${Date.now()}`;
const newContributor: ContributorConfig = {
id: newId,
type: 'static',
priority: contributors.length * 10,
enabled: true,
content: '',
};
onChange({
contributors: [...contributors, newContributor],
});
setExpandedContributors((prev) => new Set(prev).add(newId));
};
const removeContributor = (id: string) => {
onChange({
contributors: contributors.filter((c) => c.id !== id),
});
setExpandedContributors((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const updateContributor = (id: string, updates: Partial<ContributorConfig>) => {
onChange({
contributors: contributors.map((c) => {
if (c.id === id) {
// If ID is changing, handle the ID change
if (updates.id && updates.id !== id) {
// Update expanded state
setExpandedContributors((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
next.add(updates.id!);
}
return next;
});
}
// If type is changing, create a new contributor with the new type
if (updates.type && updates.type !== c.type) {
const baseFields = {
id: updates.id || c.id,
priority:
updates.priority !== undefined ? updates.priority : c.priority,
enabled: updates.enabled !== undefined ? updates.enabled : c.enabled,
};
if (updates.type === 'static') {
return {
...baseFields,
type: 'static',
content: '',
} as ContributorConfig;
} else if (updates.type === 'dynamic') {
return {
...baseFields,
type: 'dynamic',
source: 'date',
} as ContributorConfig;
} else if (updates.type === 'file') {
return { ...baseFields, type: 'file', files: [] } as ContributorConfig;
}
}
return { ...c, ...updates } as ContributorConfig;
}
return c;
}),
});
};
// Get the current value for file paths (either from editing state or from config)
const getFilesValue = (id: string, files: string[]): string => {
return editingFiles[id] ?? files.join(', ');
};
// Update local editing state while typing
const setFilesValue = (id: string, value: string) => {
setEditingFiles((prev) => ({ ...prev, [id]: value }));
};
// Parse and commit files on blur
const commitFiles = (id: string, filesString: string) => {
setEditingFiles((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
const files = filesString
.split(',')
.map((f) => f.trim())
.filter(Boolean);
updateContributor(id, { files: files.length > 0 ? files : [] });
};
return (
<Collapsible
title="System Prompt"
defaultOpen={true}
open={open}
onOpenChange={onOpenChange}
errorCount={errorCount}
sectionErrors={sectionErrors}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Define how the agent should behave using multiple contributors with different
priorities.
</p>
{contributors.length === 0 ? (
<p className="text-sm text-muted-foreground">No contributors configured</p>
) : (
contributors.map((contributor) => {
const isExpanded = expandedContributors.has(contributor.id);
return (
<div
key={contributor.id}
className="border border-border rounded-lg overflow-hidden"
>
{/* Contributor Header */}
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
<button
onClick={() => toggleContributor(contributor.id)}
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<span className="font-medium text-sm">
{contributor.id}
</span>
<span className="text-xs text-muted-foreground">
({contributor.type}, priority: {contributor.priority})
</span>
{contributor.enabled === false && (
<span className="text-xs text-destructive">
(disabled)
</span>
)}
</button>
<Button
variant="ghost"
size="sm"
onClick={() => removeContributor(contributor.id)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
{/* Contributor Details */}
{isExpanded && (
<div className="px-3 py-3 space-y-3">
{/* Common Fields */}
<div>
<LabelWithTooltip
htmlFor={`contributor-id-${contributor.id}`}
tooltip="Unique identifier for this contributor"
>
ID *
</LabelWithTooltip>
<Input
id={`contributor-id-${contributor.id}`}
value={contributor.id}
onChange={(e) =>
updateContributor(contributor.id, {
id: e.target.value,
})
}
placeholder="e.g., primary, date"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<LabelWithTooltip
htmlFor={`contributor-type-${contributor.id}`}
tooltip="Type of contributor: static (fixed text), dynamic (runtime generated), or file (from files)"
>
Type *
</LabelWithTooltip>
<select
id={`contributor-type-${contributor.id}`}
value={contributor.type}
onChange={(e) =>
updateContributor(contributor.id, {
type: e.target.value as
| 'static'
| 'dynamic'
| 'file',
})
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="static">Static</option>
<option value="dynamic">Dynamic</option>
<option value="file">File</option>
</select>
</div>
<div>
<LabelWithTooltip
htmlFor={`contributor-priority-${contributor.id}`}
tooltip="Execution priority (lower numbers run first)"
>
Priority *
</LabelWithTooltip>
<Input
id={`contributor-priority-${contributor.id}`}
type="number"
value={contributor.priority}
onChange={(e) => {
const val = e.target.value;
const num = Number.parseInt(val, 10);
updateContributor(contributor.id, {
priority: Number.isNaN(num) ? 0 : num,
});
}}
placeholder="0"
min="0"
/>
</div>
</div>
<div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={contributor.enabled !== false}
onChange={(e) =>
updateContributor(contributor.id, {
enabled: e.target.checked,
})
}
className="rounded border-input"
/>
<span>Enabled</span>
</label>
</div>
{/* Type-specific Fields */}
{contributor.type === 'static' && (
<div>
<LabelWithTooltip
htmlFor={`contributor-content-${contributor.id}`}
tooltip="Static content for the system prompt"
>
Content *
</LabelWithTooltip>
<Textarea
id={`contributor-content-${contributor.id}`}
value={contributor.content}
onChange={(e) =>
updateContributor(contributor.id, {
content: e.target.value,
})
}
placeholder="You are a helpful assistant..."
rows={8}
className="font-mono text-sm"
/>
</div>
)}
{contributor.type === 'dynamic' && (
<div>
<LabelWithTooltip
htmlFor={`contributor-source-${contributor.id}`}
tooltip="Source for dynamic content generation"
>
Source *
</LabelWithTooltip>
<select
id={`contributor-source-${contributor.id}`}
value={contributor.source}
onChange={(e) =>
updateContributor(contributor.id, {
source: e.target.value as Extract<
ContributorConfig,
{ type: 'dynamic' }
>['source'],
})
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{PROMPT_GENERATOR_SOURCES.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
</div>
)}
{contributor.type === 'file' && (
<>
<div>
<LabelWithTooltip
htmlFor={`contributor-files-${contributor.id}`}
tooltip="File paths to include, comma-separated"
>
Files *
</LabelWithTooltip>
<Input
id={`contributor-files-${contributor.id}`}
value={getFilesValue(
contributor.id,
contributor.files
)}
onChange={(e) =>
setFilesValue(
contributor.id,
e.target.value
)
}
onBlur={(e) =>
commitFiles(
contributor.id,
e.target.value
)
}
placeholder="./commands/context.md, ./commands/rules.txt"
className="font-mono"
/>
</div>
{/* File Options */}
<details className="border border-border rounded-md p-2">
<summary className="text-sm font-medium cursor-pointer">
File Options
</summary>
<div className="mt-3 space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={
contributor.options
?.includeFilenames !== false
}
onChange={(e) =>
updateContributor(
contributor.id,
{
options: {
...(contributor.options ??
{}),
includeFilenames:
e.target
.checked,
},
}
)
}
className="rounded border-input"
/>
<span>
Include filenames as headers
</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={
contributor.options
?.includeMetadata === true
}
onChange={(e) =>
updateContributor(
contributor.id,
{
options: {
...(contributor.options ??
{}),
includeMetadata:
e.target
.checked,
},
}
)
}
className="rounded border-input"
/>
<span>Include file metadata</span>
</label>
<div>
<LabelWithTooltip
htmlFor={`contributor-separator-${contributor.id}`}
tooltip="Separator between multiple files"
>
Separator
</LabelWithTooltip>
<Input
id={`contributor-separator-${contributor.id}`}
value={
contributor.options
?.separator ?? '\n\n---\n\n'
}
onChange={(e) =>
updateContributor(
contributor.id,
{
options: {
...(contributor.options ??
{}),
separator:
e.target.value,
},
}
)
}
placeholder="\n\n---\n\n"
/>
</div>
<div>
<LabelWithTooltip
htmlFor={`contributor-error-handling-${contributor.id}`}
tooltip="How to handle missing or unreadable files"
>
Error Handling
</LabelWithTooltip>
<select
id={`contributor-error-handling-${contributor.id}`}
value={
contributor.options
?.errorHandling || 'skip'
}
onChange={(e) =>
updateContributor(
contributor.id,
{
options: {
...(contributor.options ??
{}),
errorHandling: e
.target
.value as
| 'skip'
| 'error',
},
}
)
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="skip">
Skip missing files
</option>
<option value="error">
Error on missing files
</option>
</select>
</div>
<div>
<LabelWithTooltip
htmlFor={`contributor-max-file-size-${contributor.id}`}
tooltip="Maximum file size in bytes"
>
Max File Size (bytes)
</LabelWithTooltip>
<Input
id={`contributor-max-file-size-${contributor.id}`}
type="number"
value={
contributor.options
?.maxFileSize || 100000
}
onChange={(e) => {
const val = e.target.value;
const num = Number.parseInt(
val,
10
);
updateContributor(
contributor.id,
{
options: {
...(contributor.options ??
{}),
maxFileSize:
Number.isNaN(
num
)
? undefined
: num,
},
}
);
}}
placeholder="100000"
min="1"
/>
</div>
</div>
</details>
</>
)}
</div>
)}
</div>
);
})
)}
{/* Add Contributor Button */}
<Button onClick={addContributor} variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Contributor
</Button>
{errors.systemPrompt && (
<p className="text-xs text-destructive mt-1">{errors.systemPrompt}</p>
)}
</div>
</Collapsible>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Input } from '../../ui/input';
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
import { Collapsible } from '../../ui/collapsible';
import type { AgentConfig } from '@dexto/core';
import {
TOOL_CONFIRMATION_MODES,
ALLOWED_TOOLS_STORAGE_TYPES,
DEFAULT_TOOL_CONFIRMATION_MODE,
DEFAULT_ALLOWED_TOOLS_STORAGE,
} from '@dexto/core';
type ToolConfirmationConfig = NonNullable<AgentConfig['toolConfirmation']>;
interface ToolConfirmationSectionProps {
value: ToolConfirmationConfig;
onChange: (value: ToolConfirmationConfig) => void;
errors?: Record<string, string>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
errorCount?: number;
sectionErrors?: string[];
}
export function ToolConfirmationSection({
value,
onChange,
errors = {},
open,
onOpenChange,
errorCount = 0,
sectionErrors = [],
}: ToolConfirmationSectionProps) {
const handleChange = (updates: Partial<ToolConfirmationConfig>) => {
onChange({ ...value, ...updates });
};
const updateAllowedToolsStorage = (type: 'memory' | 'storage') => {
onChange({
...value,
allowedToolsStorage: type,
});
};
return (
<Collapsible
title="Tool Confirmation"
defaultOpen={false}
open={open}
onOpenChange={onOpenChange}
errorCount={errorCount}
sectionErrors={sectionErrors}
>
<div className="space-y-4">
{/* Confirmation Mode */}
<div>
<LabelWithTooltip
htmlFor="confirmation-mode"
tooltip="How the agent handles tool execution requests"
>
Confirmation Mode
</LabelWithTooltip>
<select
id="confirmation-mode"
value={value.mode || DEFAULT_TOOL_CONFIRMATION_MODE}
onChange={(e) =>
handleChange({
mode: e.target.value as 'auto-approve' | 'manual' | 'auto-deny',
})
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{TOOL_CONFIRMATION_MODES.map((mode) => (
<option key={mode} value={mode}>
{mode === 'auto-approve'
? 'Auto-approve'
: mode === 'manual'
? 'Manual'
: 'Auto-deny'}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{value.mode === 'manual'
? 'Require explicit approval before executing tools'
: value.mode === 'auto-deny'
? 'Automatically deny all tool executions'
: 'Automatically approve tool executions'}
</p>
</div>
{/* Timeout */}
{value.mode === 'manual' && (
<div>
<LabelWithTooltip
htmlFor="confirmation-timeout"
tooltip="How long to wait for approval before timing out"
>
Timeout (seconds)
</LabelWithTooltip>
<Input
id="confirmation-timeout"
type="number"
value={value.timeout || ''}
onChange={(e) =>
handleChange({
timeout: e.target.value
? parseInt(e.target.value, 10)
: undefined,
})
}
min="1"
placeholder="e.g., 60"
aria-invalid={!!errors['toolConfirmation.timeout']}
/>
{errors['toolConfirmation.timeout'] && (
<p className="text-xs text-destructive mt-1">
{errors['toolConfirmation.timeout']}
</p>
)}
</div>
)}
{/* Allowed Tools Storage */}
<div>
<LabelWithTooltip
htmlFor="allowed-tools-storage"
tooltip="Where to store the list of pre-approved tools (memory or persistent storage)"
>
Allowed Tools Storage
</LabelWithTooltip>
<select
id="allowed-tools-storage"
value={value.allowedToolsStorage || DEFAULT_ALLOWED_TOOLS_STORAGE}
onChange={(e) =>
updateAllowedToolsStorage(e.target.value as 'memory' | 'storage')
}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{ALLOWED_TOOLS_STORAGE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
</Collapsible>
);
}