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,598 @@
import React, { useState } from 'react';
import type { ServerRegistryEntry } from '@dexto/registry';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Alert, AlertDescription } from './ui/alert';
import { Plus, Save } from 'lucide-react';
import { KeyValueEditor } from './ui/key-value-editor';
interface HeaderPair {
key: string;
value: string;
id: string;
}
interface AddCustomServerModalProps {
isOpen: boolean;
onClose: () => void;
onAddServer: (
entry: Omit<ServerRegistryEntry, 'id' | 'isOfficial' | 'lastUpdated'>
) => Promise<void>;
}
export default function AddCustomServerModal({
isOpen,
onClose,
onAddServer,
}: AddCustomServerModalProps) {
const [formData, setFormData] = useState<{
name: string;
description: string;
category:
| 'productivity'
| 'development'
| 'research'
| 'creative'
| 'data'
| 'communication'
| 'custom';
icon: string;
version: string;
author: string;
homepage: string;
config: {
type: 'stdio' | 'sse' | 'http';
command: string;
args: string[];
url: string;
env: Record<string, string>;
headers: Record<string, string>;
timeout: number;
};
tags: string[];
isInstalled: boolean;
requirements: {
platform: 'win32' | 'darwin' | 'linux' | 'all';
node: string;
python: string;
dependencies: string[];
};
}>({
name: '',
description: '',
category: 'custom',
icon: '',
version: '',
author: '',
homepage: '',
config: {
type: 'stdio',
command: '',
args: [],
url: '',
env: {},
headers: {},
timeout: 30000,
},
tags: [],
isInstalled: false,
requirements: {
platform: 'all',
node: '',
python: '',
dependencies: [],
},
});
const [argsInput, setArgsInput] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [envInput, setEnvInput] = useState('');
const [headerPairs, setHeaderPairs] = useState<HeaderPair[]>([]);
const [dependenciesInput, setDependenciesInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const categories = [
{ value: 'productivity', label: 'Productivity' },
{ value: 'development', label: 'Development' },
{ value: 'research', label: 'Research' },
{ value: 'creative', label: 'Creative' },
{ value: 'data', label: 'Data' },
{ value: 'communication', label: 'Communication' },
{ value: 'custom', label: 'Custom' },
];
const platforms = [
{ value: 'all', label: 'All Platforms' },
{ value: 'win32', label: 'Windows' },
{ value: 'darwin', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
// Parse inputs
const args = argsInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const tags = tagsInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const dependencies = dependenciesInput
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Parse environment variables
const env: Record<string, string> = {};
if (envInput.trim()) {
const envLines = envInput.split('\n');
for (const line of envLines) {
// Skip empty lines
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
// Split only at the first '=' character
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex > 0) {
// Key must exist (equalIndex > 0, not >= 0)
const key = trimmedLine.substring(0, equalIndex).trim();
const value = trimmedLine.substring(equalIndex + 1).trim();
// Only add if key is not empty
if (key) {
env[key] = value; // Value can be empty string
}
}
}
}
// Convert header pairs to record
const headers: Record<string, string> = {};
headerPairs.forEach((pair) => {
if (pair.key.trim() && pair.value.trim()) {
headers[pair.key.trim()] = pair.value.trim();
}
});
// Validate required fields
if (!formData.name.trim()) {
throw new Error('Server name is required');
}
if (!formData.description.trim()) {
throw new Error('Description is required');
}
if (formData.config.type === 'stdio' && !formData.config.command.trim()) {
throw new Error('Command is required for stdio servers');
}
if (formData.config.type === 'sse' && !formData.config.url.trim()) {
throw new Error('URL is required for SSE servers');
}
if (formData.config.type === 'http' && !formData.config.url.trim()) {
throw new Error('URL is required for HTTP servers');
}
const entry: Omit<ServerRegistryEntry, 'id' | 'isOfficial' | 'lastUpdated'> = {
...formData,
config: {
...formData.config,
args,
env,
headers,
},
tags,
requirements: {
...formData.requirements,
dependencies,
},
};
await onAddServer(entry);
onClose();
// Reset form
setFormData({
name: '',
description: '',
category: 'custom',
icon: '',
version: '',
author: '',
homepage: '',
config: {
type: 'stdio',
command: '',
args: [],
url: '',
env: {},
headers: {},
timeout: 30000,
},
tags: [],
isInstalled: false,
requirements: {
platform: 'all',
node: '',
python: '',
dependencies: [],
},
});
setArgsInput('');
setTagsInput('');
setEnvInput('');
setHeaderPairs([]);
setDependenciesInput('');
} catch (err: any) {
setError(err.message || 'Failed to add custom server');
} finally {
setIsSubmitting(false);
}
};
const handleConfigChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
config: {
...prev.config,
[name]: value,
},
}));
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Add Custom Server to Registry
</DialogTitle>
<DialogDescription>
Add your own custom MCP server configuration to the registry for easy reuse.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Basic Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Server Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Custom Server"
required
/>
</div>
<div>
<Label htmlFor="category">Category</Label>
<Select
value={formData.category}
onValueChange={(value: any) =>
setFormData((prev) => ({ ...prev, category: value }))
}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="Describe what this server does..."
required
/>
</div>
{/* Server Configuration */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Server Configuration</h3>
<div>
<Label htmlFor="serverType">Server Type</Label>
<Select
value={formData.config.type}
onValueChange={(value: 'stdio' | 'sse' | 'http') =>
setFormData((prev) => ({
...prev,
config: { ...prev.config, type: value },
}))
}
>
<SelectTrigger id="serverType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">sse</SelectItem>
<SelectItem value="http">http</SelectItem>
</SelectContent>
</Select>
</div>
{formData.config.type === 'stdio' ? (
<>
<div>
<Label htmlFor="command">Command *</Label>
<Input
id="command"
value={formData.config.command}
onChange={(e) =>
setFormData((prev) => ({
...prev,
config: { ...prev.config, command: e.target.value },
}))
}
placeholder="npx or python"
required
/>
</div>
<div>
<Label htmlFor="args">Arguments</Label>
<Input
id="args"
value={argsInput}
onChange={(e) => setArgsInput(e.target.value)}
placeholder="Comma-separated: -m,script.py,--port,8080"
/>
</div>
</>
) : formData.config.type === 'sse' ? (
<div className="space-y-2">
<Label htmlFor="url">URL *</Label>
<Input
id="url"
name="url"
value={formData.config.url}
onChange={handleConfigChange}
placeholder="http://localhost:8080/sse"
required
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="url">URL *</Label>
<Input
id="url"
name="url"
value={formData.config.url}
onChange={handleConfigChange}
placeholder="https://example.com"
required
/>
</div>
)}
{formData.config.type === 'stdio' && (
<div>
<Label htmlFor="env">Environment Variables</Label>
<Textarea
id="env"
value={envInput}
onChange={(e) => setEnvInput(e.target.value)}
placeholder={`KEY1=value1\nKEY2=value2`}
rows={3}
/>
</div>
)}
{(formData.config.type === 'sse' || formData.config.type === 'http') && (
<div className="space-y-4">
<KeyValueEditor
label="Headers"
pairs={headerPairs}
onChange={setHeaderPairs}
placeholder={{
key: 'Authorization',
value: 'Bearer your-token',
}}
keyLabel="Header"
valueLabel="Value"
/>
</div>
)}
</div>
{/* Additional Information */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="tags">Tags</Label>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="Comma-separated: file, database, api"
/>
</div>
<div>
<Label htmlFor="icon">Icon (Emoji)</Label>
<Input
id="icon"
value={formData.icon}
onChange={(e) =>
setFormData((prev) => ({ ...prev, icon: e.target.value }))
}
placeholder="⚡"
maxLength={2}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="version">Version</Label>
<Input
id="version"
value={formData.version}
onChange={(e) =>
setFormData((prev) => ({ ...prev, version: e.target.value }))
}
placeholder="1.0.0"
/>
</div>
<div>
<Label htmlFor="author">Author</Label>
<Input
id="author"
value={formData.author}
onChange={(e) =>
setFormData((prev) => ({ ...prev, author: e.target.value }))
}
placeholder="Your Name"
/>
</div>
</div>
<div>
<Label htmlFor="homepage">Homepage URL</Label>
<Input
id="homepage"
value={formData.homepage}
onChange={(e) =>
setFormData((prev) => ({ ...prev, homepage: e.target.value }))
}
placeholder="https://github.com/youruser/yourserver"
/>
</div>
{/* Requirements Section */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Requirements</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="platform">Platform</Label>
<Select
value={formData.requirements.platform}
onValueChange={(value: 'win32' | 'darwin' | 'linux' | 'all') =>
setFormData((prev) => ({
...prev,
requirements: { ...prev.requirements, platform: value },
}))
}
>
<SelectTrigger id="platform">
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms.map((platform) => (
<SelectItem key={platform.value} value={platform.value}>
{platform.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="dependencies">Dependencies</Label>
<Input
id="dependencies"
value={dependenciesInput}
onChange={(e) => setDependenciesInput(e.target.value)}
placeholder="Comma-separated: package1, package2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="nodeVersion">Node.js Version</Label>
<Input
id="nodeVersion"
value={formData.requirements.node}
onChange={(e) =>
setFormData((prev) => ({
...prev,
requirements: {
...prev.requirements,
node: e.target.value,
},
}))
}
placeholder=">=16.0.0"
/>
</div>
<div>
<Label htmlFor="pythonVersion">Python Version</Label>
<Input
id="pythonVersion"
value={formData.requirements.python}
onChange={(e) =>
setFormData((prev) => ({
...prev,
requirements: {
...prev.requirements,
python: e.target.value,
},
}))
}
placeholder=">=3.8"
/>
</div>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
'Adding...'
) : (
<>
<Save className="h-4 w-4 mr-2" />
Add to Registry
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

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

View File

@@ -0,0 +1,643 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys.js';
import { cn } from '@/lib/utils';
import {
useAgents,
useAgentPath,
useSwitchAgent,
useInstallAgent,
useUninstallAgent,
} from '../hooks/useAgents';
import { useRecentAgentsStore } from '@/lib/stores/recentAgentsStore';
import { useSessionStore } from '@/lib/stores/sessionStore';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
ChevronDown,
Check,
DownloadCloud,
Sparkles,
Trash2,
BadgeCheck,
Plus,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import CreateAgentModal from './CreateAgentModal';
import { useAnalytics } from '@/lib/analytics/index.js';
type AgentItem = {
id: string;
name: string;
description: string;
author?: string;
tags?: string[];
type: 'builtin' | 'custom';
};
type AgentsResponse = {
installed: AgentItem[];
available: AgentItem[];
current: { id: string | null; name: string | null };
};
type AgentSelectorProps = {
mode?: 'default' | 'badge' | 'title';
};
export default function AgentSelector({ mode = 'default' }: AgentSelectorProps) {
const navigate = useNavigate();
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const analytics = useAnalytics();
const analyticsRef = useRef(analytics);
const recentAgents = useRecentAgentsStore((state) => state.recentAgents);
const addToRecentAgents = useRecentAgentsStore((state) => state.addRecentAgent);
const [switching, setSwitching] = useState(false);
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
// Keep analytics ref up to date to avoid stale closure issues
useEffect(() => {
analyticsRef.current = analytics;
}, [analytics]);
const queryClient = useQueryClient();
// Invalidate all agent-specific queries when switching agents
// This replaces the DOM event pattern (dexto:agentSwitched)
const invalidateAgentSpecificQueries = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
queryClient.invalidateQueries({ queryKey: ['sessions', 'history'] }); // All session histories
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: ['servers', 'tools'] });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
queryClient.invalidateQueries({ queryKey: ['greeting'] }); // Hierarchical invalidation
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config }); // Agent config (CustomizePanel)
}, [queryClient]);
// Check if an agent path is from the global ~/.dexto directory
// Global pattern: /Users/<user>/.dexto/agents or /home/<user>/.dexto/agents
// Also handles Windows: C:\Users\<user>\.dexto\agents
const isGlobalAgent = useCallback((path: string): boolean => {
// Match paths where .dexto appears within first 4 segments (home directory level)
// POSIX: /Users/username/.dexto/agents/... (index 2)
// Windows: C:/Users/username/.dexto/agents/... (index 3, drive letter adds extra segment)
// Project: /Users/username/Projects/my-project/.dexto/agents/... (5+ segments)
// Normalize Windows backslashes to forward slashes for consistent parsing
const normalized = path.replace(/\\/g, '/');
const segments = normalized.split('/').filter(Boolean);
const dextoIndex = segments.findIndex((s) => s === '.dexto');
return dextoIndex >= 0 && dextoIndex <= 3;
}, []);
// Fetch agents list and path using typed hooks
const { data: agentsData, isLoading: agentsLoading, refetch: refetchAgents } = useAgents();
const { data: currentAgentPathData } = useAgentPath();
const installed = useMemo(() => agentsData?.installed || [], [agentsData?.installed]);
const available = useMemo(() => agentsData?.available || [], [agentsData?.available]);
const currentId = agentsData?.current.id || null;
const currentAgentPath = currentAgentPathData ?? null;
// Agent mutations using typed hooks
const switchAgentMutation = useSwitchAgent();
const installAgentMutation = useInstallAgent();
const deleteAgentMutation = useUninstallAgent();
// Sync current agent path to recent agents when it loads
useEffect(() => {
if (currentAgentPath?.path && currentAgentPath?.name) {
addToRecentAgents({
id: currentAgentPath.name,
name: currentAgentPath.name,
path: currentAgentPath.path,
});
}
}, [currentAgentPath, addToRecentAgents]);
const loading = agentsLoading;
const handleSwitch = useCallback(
async (agentId: string) => {
try {
setSwitching(true);
// Check if the agent exists in the installed list
const agent = installed.find((agent) => agent.id === agentId);
if (!agent) {
console.error(`Agent not found in installed list: ${agentId}`);
throw new Error(
`Agent '${agentId}' not found. Please refresh the agents list.`
);
}
// Capture current agent ID before switch
const fromAgentId = currentId;
await switchAgentMutation.mutateAsync({ id: agentId });
setOpen(false); // Close dropdown after successful switch
// Track agent switch using ref to avoid stale closure
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agentId,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Navigate back to home after switching agents
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
alert(`Failed to switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
installed,
navigate,
currentId,
currentSessionId,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleSwitchToPath = useCallback(
async (agent: { id: string; name: string; path: string }) => {
try {
setSwitching(true);
// Capture current agent ID before switch
const fromAgentId = currentId;
await switchAgentMutation.mutateAsync({ id: agent.id, path: agent.path });
setOpen(false); // Close dropdown after successful switch
// Add to recent agents
addToRecentAgents(agent);
// Track agent switch using ref to avoid stale closure
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agent.id,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Navigate back to home after switching agents
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
alert(`Failed to switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
addToRecentAgents,
navigate,
currentId,
currentSessionId,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleInstall = useCallback(
async (agentId: string) => {
try {
setSwitching(true);
// Capture current agent ID before operations
const fromAgentId = currentId;
// Step 1: Install the agent
await installAgentMutation.mutateAsync({ id: agentId });
// Step 2: Refetch agents list to ensure cache has fresh data
await queryClient.refetchQueries({ queryKey: queryKeys.agents.all });
// Step 3: Verify agent is now in installed list
const freshData = queryClient.getQueryData<AgentsResponse>(queryKeys.agents.all);
const agent = freshData?.installed.find((a) => a.id === agentId);
if (!agent) {
throw new Error(
`Agent '${agentId}' not found after installation. Please refresh.`
);
}
// Step 4: Switch to the newly installed agent
await switchAgentMutation.mutateAsync({ id: agentId });
setOpen(false);
// Step 5: Track the switch analytics
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agentId,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Step 6: Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Step 7: Navigate to home
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Install/switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage =
err instanceof Error ? err.message : 'Failed to install/switch agent';
alert(`Failed to install/switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
navigate,
currentId,
currentSessionId,
queryClient,
installAgentMutation,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleDelete = useCallback(
async (agent: AgentItem, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering switch when clicking delete
if (!confirm(`Are you sure you want to delete the custom agent "${agent.name}"?`)) {
return;
}
try {
setSwitching(true);
await deleteAgentMutation.mutateAsync({ id: agent.id });
} catch (err) {
console.error(
`Delete agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to delete agent';
alert(`Failed to delete agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[deleteAgentMutation]
);
const currentLabel = useMemo(() => {
if (!currentId) return 'Choose Agent';
const match =
installed.find((agent) => agent.id === currentId) ||
available.find((agent) => agent.id === currentId);
return match?.name ?? currentId;
}, [available, currentId, installed]);
const handleAgentCreated = useCallback(
async (_agentName: string) => {
await refetchAgents();
},
[refetchAgents]
);
const getButtonClassName = (mode: string) => {
switch (mode) {
case 'badge':
// Teal text, transparent bg
return `h-9 px-4 text-lg font-medium rounded-lg bg-transparent text-teal-600 hover:bg-muted/50 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300 transition-colors min-w-[120px] max-w-[180px] md:min-w-[160px] md:max-w-[280px] lg:max-w-[400px] xl:max-w-[500px]`;
case 'title':
return `h-11 px-4 text-lg font-bold rounded-lg bg-gradient-to-r from-teal-500/30 to-teal-500/40 text-teal-600 hover:from-teal-500/50 hover:to-teal-500/60 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/40 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
default:
return `h-10 px-3 text-sm rounded-lg bg-teal-500/40 text-teal-600 hover:bg-teal-500/50 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/50 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant={mode === 'badge' ? 'ghost' : 'default'}
size="sm"
className={getButtonClassName(mode)}
disabled={switching}
>
<div className="flex items-center justify-between w-full min-w-0">
{mode !== 'badge' && (
<Sparkles className="w-4 h-4 mr-2 flex-shrink-0" />
)}
<span
className={cn(
'truncate min-w-0',
mode === 'badge'
? 'flex-1 text-left'
: 'flex-1 text-center px-1'
)}
>
{switching
? 'Switching...'
: mode === 'title'
? `Agent: ${currentLabel}`
: currentLabel}
</span>
<ChevronDown className="w-4 h-4 ml-1.5 flex-shrink-0 opacity-60" />
</div>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Select agent</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="w-80 max-h-96 overflow-y-auto shadow-xl border-border/30"
>
{loading && (
<DropdownMenuItem disabled className="text-center text-muted-foreground">
Loading agents...
</DropdownMenuItem>
)}
{!loading && (
<>
{/* Create New Agent Button */}
<DropdownMenuItem
onClick={() => {
setCreateModalOpen(true);
setOpen(false);
}}
disabled={switching}
className="cursor-pointer py-3 px-3 mx-1 my-1 bg-gradient-to-r from-primary/10 to-primary/5 hover:from-primary/15 hover:to-primary/10 border border-primary/20 hover:border-primary/30 transition-all rounded-md shadow-sm"
>
<div className="flex items-center gap-2 w-full">
<Plus className="w-4 h-4 text-primary" />
<span className="font-semibold text-primary">New Agent</span>
</div>
</DropdownMenuItem>
{/* Current Agent (if loaded from file and not in installed list) */}
{currentAgentPath &&
!installed.some((a) => a.id === currentAgentPath.name) && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-teal-600 dark:text-teal-400 uppercase tracking-wider border-b border-border/20">
Currently Active
</div>
<DropdownMenuItem
onClick={() =>
handleSwitchToPath({
id: currentAgentPath.name,
name: currentAgentPath.name,
path: currentAgentPath.path,
})
}
disabled={
switching || currentId === currentAgentPath.name
}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{currentAgentPath.name}
</span>
{currentId === currentAgentPath.name && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
Loaded from file
</p>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Recent Agents */}
{recentAgents.length > 0 && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Recent
</div>
{recentAgents
.filter(
(ra) =>
!installed.some((a) => a.id === ra.id) &&
ra.id !== currentAgentPath?.name &&
!isGlobalAgent(ra.path) // Filter out global dexto directory agents
)
.slice(0, 3)
.map((agent) => (
<DropdownMenuItem
key={agent.path}
onClick={() => handleSwitchToPath(agent)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p
className="text-xs text-muted-foreground mt-0.5 truncate"
title={agent.path}
>
{agent.path}
</p>
</div>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{/* Installed Custom Agents */}
{installed.filter((a) => a.type === 'custom').length > 0 && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-primary uppercase tracking-wider flex items-center gap-1 border-b border-border/20">
<BadgeCheck className="w-3 h-3" />
Custom Agents
</div>
{installed
.filter((a) => a.type === 'custom')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSwitch(agent.id)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
<button
onClick={(e) => handleDelete(agent, e)}
disabled={switching}
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete custom agent"
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
</div>
</DropdownMenuItem>
))}
</>
)}
{/* Installed Builtin Agents */}
{installed.filter((a) => a.type === 'builtin').length > 0 && (
<>
{installed.filter((a) => a.type === 'custom').length > 0 && (
<DropdownMenuSeparator />
)}
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Installed
</div>
{installed
.filter((a) => a.type === 'builtin')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSwitch(agent.id)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{/* Available Builtin Agents */}
{available.filter((a) => a.type === 'builtin').length > 0 && (
<>
{installed.length > 0 && <DropdownMenuSeparator />}
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Available
</div>
{available
.filter((a) => a.type === 'builtin')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleInstall(agent.id)}
disabled={switching}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
<DownloadCloud className="w-4 h-4 text-blue-600 flex-shrink-0" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{!loading && installed.length === 0 && available.length === 0 && (
<DropdownMenuItem
disabled
className="text-center text-muted-foreground"
>
No agents found
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<CreateAgentModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onAgentCreated={handleAgentCreated}
/>
</>
);
}

View File

@@ -0,0 +1,372 @@
import React, { useState } from 'react';
import { useCreateAgent, type CreateAgentPayload } from '../hooks/useAgents';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { AlertCircle, Loader2, Eye, EyeOff, Info } from 'lucide-react';
import { LLM_PROVIDERS } from '@dexto/core';
interface CreateAgentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentCreated?: (agentName: string) => void;
}
interface FormData {
id: string;
idManuallyEdited: boolean;
name: string;
description: string;
provider: string;
model: string;
apiKey: string;
systemPrompt: string;
}
const initialFormData: FormData = {
id: '',
idManuallyEdited: false,
name: '',
description: '',
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
apiKey: '',
systemPrompt: '',
};
// Convert name to a valid ID (lowercase, hyphens, no special chars)
function nameToId(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Spaces to hyphens
.replace(/-+/g, '-') // Multiple hyphens to single
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
}
export default function CreateAgentModal({
open,
onOpenChange,
onAgentCreated,
}: CreateAgentModalProps) {
const [form, setForm] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [createError, setCreateError] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const createAgentMutation = useCreateAgent();
const isCreating = createAgentMutation.isPending;
const updateField = (field: keyof FormData, value: string) => {
setForm((prev) => {
const next = { ...prev, [field]: value };
// Auto-generate ID from name if ID hasn't been manually edited
if (field === 'name' && !prev.idManuallyEdited) {
next.id = nameToId(value);
}
// Mark ID as manually edited if user types in it
if (field === 'id') {
next.idManuallyEdited = true;
}
return next;
});
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.id.trim()) {
newErrors.id = 'Required';
} else if (!/^[a-z0-9-]+$/.test(form.id)) {
newErrors.id = 'Lowercase letters, numbers, and hyphens only';
}
if (!form.name.trim()) {
newErrors.name = 'Required';
}
if (!form.description.trim()) {
newErrors.description = 'Required';
}
if (!form.provider) {
newErrors.provider = 'Required';
}
if (!form.model.trim()) {
newErrors.model = 'Required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCreate = async () => {
if (!validateForm()) return;
setCreateError(null);
const payload: CreateAgentPayload = {
id: form.id.trim(),
name: form.name.trim(),
description: form.description.trim(),
config: {
llm: {
provider: form.provider as CreateAgentPayload['config']['llm']['provider'],
model: form.model.trim(),
apiKey: form.apiKey.trim() || undefined,
},
...(form.systemPrompt.trim() && {
systemPrompt: {
contributors: [
{
id: 'primary',
type: 'static' as const,
priority: 0,
enabled: true,
content: form.systemPrompt.trim(),
},
],
},
}),
},
};
createAgentMutation.mutate(payload, {
onSuccess: (data) => {
setForm(initialFormData);
setErrors({});
onOpenChange(false);
if (onAgentCreated && data.id) {
onAgentCreated(data.id);
}
},
onError: (error: Error) => {
setCreateError(error.message);
},
});
};
const handleCancel = () => {
setForm(initialFormData);
setErrors({});
setCreateError(null);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border/40">
<DialogTitle className="text-base">Create Agent</DialogTitle>
<DialogDescription className="text-sm">
Configure your new agent. Advanced options can be set after creation.
</DialogDescription>
</DialogHeader>
{/* Error */}
{createError && (
<div className="mx-5 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive">{createError}</p>
</div>
)}
{/* Form */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Identity */}
<Section title="Identity">
<Field label="Name" required error={errors.name}>
<Input
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="My Agent"
aria-invalid={!!errors.name}
/>
</Field>
<Field
label="ID"
required
error={errors.id}
hint={!form.idManuallyEdited ? 'Auto-generated from name' : undefined}
>
<Input
value={form.id}
onChange={(e) => updateField('id', e.target.value)}
placeholder="my-agent"
aria-invalid={!!errors.id}
className="font-mono text-sm"
/>
</Field>
<Field label="Description" required error={errors.description}>
<Input
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="A helpful assistant for..."
aria-invalid={!!errors.description}
/>
</Field>
</Section>
{/* Model */}
<Section title="Language Model">
<div className="grid grid-cols-2 gap-3">
<Field label="Provider" required error={errors.provider}>
<Select
value={form.provider}
onValueChange={(value) => updateField('provider', value)}
>
<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.model}>
<Input
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
placeholder="claude-sonnet-4-5-20250929"
aria-invalid={!!errors.model}
/>
</Field>
</div>
<Field label="API Key" hint="Leave empty to use environment variable">
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
value={form.apiKey}
onChange={(e) => updateField('apiKey', e.target.value)}
placeholder="$ANTHROPIC_API_KEY"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 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>
</Field>
</Section>
{/* System Prompt */}
<Section title="System Prompt" optional>
<Field>
<Textarea
value={form.systemPrompt}
onChange={(e) => updateField('systemPrompt', e.target.value)}
placeholder="You are a helpful assistant..."
rows={4}
className="font-mono text-sm resize-y"
/>
</Field>
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1">
<Info className="h-3 w-3" />
You can add MCP servers and other options after creation
</p>
</Section>
</div>
{/* Footer */}
<DialogFooter className="px-5 py-4 border-t border-border/40 bg-muted/20">
<Button variant="outline" onClick={handleCancel} disabled={isCreating}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// SHARED COMPONENTS
// ============================================================================
function Section({
title,
optional,
children,
}: {
title: string;
optional?: boolean;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-2.5 flex items-baseline gap-2">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{optional && (
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">
Optional
</span>
)}
</div>
<div className="space-y-3">{children}</div>
</div>
);
}
function Field({
label,
required,
hint,
error,
children,
}: {
label?: string;
required?: boolean;
hint?: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{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/70 mt-1">{hint}</p>}
{error && <p className="text-[11px] text-destructive mt-1">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Alert, AlertDescription } from './ui/alert';
import { useSaveApiKey, type LLMProvider } from './hooks/useLLM';
export type ApiKeyModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: LLMProvider;
primaryEnvVar: string;
onSaved: (meta: { provider: string; envVar: string }) => void;
};
export function ApiKeyModal({
open,
onOpenChange,
provider,
primaryEnvVar,
onSaved,
}: ApiKeyModalProps) {
const [apiKey, setApiKey] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const saveApiKeyMutation = useSaveApiKey();
const submit = () => {
if (!apiKey.trim()) {
setError('API key is required');
return;
}
setError(null);
saveApiKeyMutation.mutate(
{ provider, apiKey },
{
onSuccess: (data) => {
onSaved({ provider: data.provider, envVar: data.envVar });
onOpenChange(false);
setApiKey('');
setError(null);
},
onError: (err: Error) => {
setError(err.message || 'Failed to save API key');
},
}
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Set {provider} API Key</DialogTitle>
<DialogDescription>
This key will be stored in your .env (env var {primaryEnvVar}). It is not
shared with the client.
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Enter ${provider} API key`}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saveApiKeyMutation.isPending}
>
Cancel
</Button>
<Button onClick={submit} disabled={saveApiKeyMutation.isPending}>
{saveApiKeyMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,449 @@
import React, { useState } from 'react';
import { AlertCircle, ChevronRight, CheckCircle2, XCircle, Terminal, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import type { ApprovalEvent } from './ToolConfirmationHandler';
import type { JSONSchema7 } from 'json-schema';
import { ApprovalType } from '@dexto/core';
interface ApprovalTimelineProps {
approval: ApprovalEvent;
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
onDeny: () => void;
}
export function ApprovalTimeline({ approval, onApprove, onDeny }: ApprovalTimelineProps) {
const [expanded, setExpanded] = useState(false);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [rememberChoice, setRememberChoice] = useState(false);
const updateFormField = (fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
if (formErrors[fieldName]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
};
const handleApprove = () => {
if (approval.type === ApprovalType.ELICITATION) {
const { schema } = approval.metadata;
const required = (schema.required as string[]) || [];
const errors: Record<string, string> = {};
for (const fieldName of required) {
const value = formData[fieldName];
const isEmptyString = typeof value === 'string' && value.trim() === '';
if (value === undefined || value === null || isEmptyString) {
errors[fieldName] = 'This field is required';
}
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
onApprove(formData);
} else {
onApprove(undefined, rememberChoice);
}
};
// Generate display info based on approval type
const getDisplayInfo = () => {
let summary = '';
let displayName = '';
let source = '';
if (approval.type === ApprovalType.COMMAND_CONFIRMATION) {
displayName = 'bash';
summary = 'Command requires approval';
source = 'system';
} else if (approval.type === ApprovalType.TOOL_CONFIRMATION) {
const toolName = approval.metadata.toolName;
if (toolName.startsWith('mcp__')) {
displayName = toolName.substring(5);
source = 'mcp';
} else {
displayName = toolName;
}
summary = `Tool requires approval`;
} else if (approval.type === ApprovalType.ELICITATION) {
displayName = approval.metadata.serverName || 'Agent';
summary = 'Information requested';
source = 'input';
}
return { summary, displayName, source };
};
const { summary, displayName, source } = getDisplayInfo();
const renderFormField = (fieldName: string, fieldSchema: JSONSchema7, isRequired: boolean) => {
const fieldType = fieldSchema.type || 'string';
const fieldValue = formData[fieldName];
const hasError = !!formErrors[fieldName];
const label = fieldSchema.title || fieldName;
if (fieldType === 'boolean') {
return (
<div key={fieldName} className="space-y-1">
<div className="flex items-center space-x-2">
<Checkbox
id={fieldName}
checked={fieldValue === true}
onCheckedChange={(checked) =>
updateFormField(fieldName, checked === true)
}
/>
<label htmlFor={fieldName} className="text-xs font-medium">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
{fieldSchema.description && (
<p className="text-[10px] text-muted-foreground/70 ml-6">
{fieldSchema.description}
</p>
)}
</div>
);
}
if (fieldType === 'number' || fieldType === 'integer') {
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-xs font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-[10px] text-muted-foreground/70">
{fieldSchema.description}
</p>
)}
<input
id={fieldName}
type="number"
step={fieldType === 'integer' ? '1' : 'any'}
value={typeof fieldValue === 'number' ? fieldValue : ''}
onChange={(e) => {
const raw = e.target.value;
const nextValue = raw === '' ? undefined : Number(raw);
updateFormField(fieldName, nextValue);
}}
className={cn(
'w-full px-2 py-1.5 border rounded text-xs bg-background',
hasError ? 'border-red-500' : 'border-border'
)}
placeholder={isRequired ? 'Required' : 'Optional'}
/>
{hasError && (
<p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>
)}
</div>
);
}
if (fieldSchema.enum && Array.isArray(fieldSchema.enum)) {
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-xs font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-[10px] text-muted-foreground/70">
{fieldSchema.description}
</p>
)}
<select
id={fieldName}
value={
fieldValue !== undefined && fieldValue !== null
? String(fieldValue)
: ''
}
onChange={(e) => {
const selected = e.target.value;
if (selected === '') {
updateFormField(fieldName, undefined);
return;
}
const matched = (fieldSchema.enum as unknown[])?.find(
(option) => String(option) === selected
);
updateFormField(fieldName, matched ?? selected);
}}
className={cn(
'w-full px-2 py-1.5 border rounded text-xs bg-background',
hasError ? 'border-red-500' : 'border-border'
)}
>
<option value="">Select...</option>
{(fieldSchema.enum as unknown[])?.map((option) => (
<option key={String(option)} value={String(option)}>
{String(option)}
</option>
))}
</select>
{hasError && (
<p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>
)}
</div>
);
}
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-xs font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-[10px] text-muted-foreground/70">
{fieldSchema.description}
</p>
)}
<input
id={fieldName}
type="text"
value={
fieldValue !== undefined &&
fieldValue !== null &&
typeof fieldValue !== 'object'
? String(fieldValue)
: ''
}
onChange={(e) => updateFormField(fieldName, e.target.value)}
className={cn(
'w-full px-2 py-1.5 border rounded text-xs bg-background',
hasError ? 'border-red-500' : 'border-border'
)}
placeholder={isRequired ? 'Required' : 'Optional'}
/>
{hasError && <p className="text-[10px] text-red-500">{formErrors[fieldName]}</p>}
</div>
);
};
return (
<div className="flex gap-2.5 animate-slide-up my-1">
{/* Timeline column */}
<div className="flex flex-col items-center">
{/* Status indicator with pulse */}
<div className="flex-shrink-0 relative">
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
<span className="absolute inset-0 h-3.5 w-3.5 rounded-full bg-amber-500/30 animate-ping" />
</div>
{/* Vertical line */}
<div className="w-px flex-1 min-h-[8px] bg-amber-500/30" />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-1">
{/* Summary line */}
<div className="space-y-1.5">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-1.5 text-left group"
>
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
{summary}
</span>
{source && (
<span className="text-[10px] text-muted-foreground/40">[{source}]</span>
)}
<ChevronRight
className={cn(
'h-2.5 w-2.5 text-muted-foreground/40 transition-transform flex-shrink-0',
expanded && 'rotate-90'
)}
/>
<span className="text-[10px] text-amber-600/60 dark:text-amber-400/60">
needs approval
</span>
</button>
{/* Tool/command name */}
<div className="text-[10px] text-muted-foreground/35">{displayName}</div>
{/* Inline action buttons (when not expanded) */}
{!expanded && approval.type !== ApprovalType.ELICITATION && (
<div className="flex gap-1.5 mt-1.5">
<Button
onClick={handleApprove}
size="sm"
className="bg-green-600 hover:bg-green-700 text-white h-6 text-[11px] px-2.5"
>
Approve
</Button>
<Button
onClick={onDeny}
variant="outline"
size="sm"
className="h-6 text-[11px] px-2.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
>
Reject
</Button>
</div>
)}
{/* For elicitation, always show expanded since form is required */}
{approval.type === ApprovalType.ELICITATION && !expanded && (
<button
onClick={() => setExpanded(true)}
className="text-[10px] text-amber-600 dark:text-amber-400 underline"
>
Click to provide input...
</button>
)}
</div>
{/* Expanded details */}
{expanded && (
<div className="mt-2 space-y-3 animate-fade-in">
{/* Command confirmation */}
{approval.type === ApprovalType.COMMAND_CONFIRMATION && (
<>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Terminal className="h-3 w-3" />
<span>Command</span>
</div>
<pre className="bg-muted/30 rounded-md p-2 text-[10px] font-mono text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
{approval.metadata.command}
</pre>
<div className="bg-amber-50 dark:bg-amber-900/20 p-2 rounded-md text-[10px] text-amber-800 dark:text-amber-200">
This command may modify your system.
</div>
</>
)}
{/* Tool confirmation */}
{approval.type === ApprovalType.TOOL_CONFIRMATION && (
<>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Wrench className="h-3 w-3" />
<span>{approval.metadata.toolName}</span>
</div>
{approval.metadata.description && (
<p className="text-xs text-foreground/70">
{approval.metadata.description}
</p>
)}
<div>
<h4 className="text-[9px] font-semibold text-muted-foreground/60 uppercase mb-1">
Arguments
</h4>
<div className="bg-muted/30 rounded-md p-1.5 space-y-0.5">
{Object.entries(approval.metadata.args || {}).map(
([key, value]) => (
<div key={key} className="flex gap-1.5 text-[10px]">
<span className="text-muted-foreground font-medium shrink-0">
{key}:
</span>
<span className="text-foreground/70 font-mono break-all">
{typeof value === 'string'
? value
: typeof value === 'object'
? JSON.stringify(value)
: String(value)}
</span>
</div>
)
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberChoice}
onCheckedChange={(checked) =>
setRememberChoice(checked === true)
}
/>
<label
htmlFor="remember"
className="text-[10px] text-muted-foreground"
>
Remember for this session
</label>
</div>
</>
)}
{/* Elicitation (form) */}
{approval.type === ApprovalType.ELICITATION && (
<>
<div className="bg-muted/30 p-2 rounded-md">
<p className="text-xs font-medium break-words">
{approval.metadata.prompt}
</p>
</div>
<div className="space-y-3">
{(() => {
const { schema } = approval.metadata;
if (
!schema?.properties ||
typeof schema.properties !== 'object'
) {
return (
<p className="text-xs text-red-600 dark:text-red-400">
Invalid form schema
</p>
);
}
const required = (schema.required as string[]) || [];
const properties = schema.properties;
return Object.entries(properties).map(
([fieldName, fieldSchema]) => {
const isRequired = required.includes(fieldName);
return renderFormField(
fieldName,
fieldSchema as JSONSchema7,
isRequired
);
}
);
})()}
</div>
</>
)}
{/* Action buttons (expanded view) */}
<div className="flex gap-1.5">
<Button
onClick={handleApprove}
size="sm"
className="flex-1 bg-green-600 hover:bg-green-700 text-white h-7 text-xs"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
{approval.type === ApprovalType.ELICITATION ? 'Submit' : 'Approve'}
</Button>
<Button
onClick={onDeny}
size="sm"
variant="outline"
className="flex-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20 h-7 text-xs"
>
<XCircle className="h-3 w-3 mr-1" />
{approval.type === ApprovalType.ELICITATION ? 'Decline' : 'Reject'}
</Button>
</div>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Paperclip, File, FileAudio } from 'lucide-react';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { cn } from '@/lib/utils';
interface AttachButtonProps {
onImageAttach: () => void;
onPdfAttach: () => void;
onAudioAttach: () => void;
className?: string;
supports?: {
image?: boolean;
pdf?: boolean;
audio?: boolean;
};
/** Use lg breakpoint instead of md for responsive text */
useLargeBreakpoint?: boolean;
}
export function AttachButton({
onImageAttach,
onPdfAttach,
onAudioAttach,
className,
supports,
useLargeBreakpoint = false,
}: AttachButtonProps) {
const [open, setOpen] = React.useState(false);
const imageSupported = supports?.image !== false; // default to true if unknown
const pdfSupported = supports?.pdf !== false; // default to true if unknown
const audioSupported = supports?.audio !== false;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 text-sm text-muted-foreground hover:text-foreground rounded-full',
useLargeBreakpoint ? 'lg:px-3' : 'md:px-3',
className
)}
aria-label="Attach File"
>
<Paperclip
className={cn('h-3 w-3', useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5')}
/>
<span className={cn('hidden', useLargeBreakpoint ? 'lg:inline' : 'md:inline')}>
Attach
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onClick={() => {
if (!imageSupported) return;
onImageAttach();
setOpen(false);
}}
className={!imageSupported ? 'opacity-50 cursor-not-allowed' : undefined}
aria-disabled={!imageSupported}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Paperclip className="h-4 w-4 mr-2" /> Image
</div>
</TooltipTrigger>
{!imageSupported && (
<TooltipContent side="bottom">
Unsupported for this model
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (!pdfSupported) return;
onPdfAttach();
setOpen(false);
}}
className={!pdfSupported ? 'opacity-50 cursor-not-allowed' : undefined}
aria-disabled={!pdfSupported}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<File className="h-4 w-4 mr-2" /> PDF
</div>
</TooltipTrigger>
{!pdfSupported && (
<TooltipContent side="bottom">
Unsupported for this model
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (!audioSupported) return;
onAudioAttach();
setOpen(false);
}}
className={!audioSupported ? 'opacity-50 cursor-not-allowed' : undefined}
aria-disabled={!audioSupported}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<FileAudio className="h-4 w-4 mr-2" /> Audio file
</div>
</TooltipTrigger>
{!audioSupported && (
<TooltipContent side="bottom">
Unsupported for this model
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface ButtonFooterProps {
leftButtons?: React.ReactNode;
rightButtons?: React.ReactNode;
className?: string;
}
export function ButtonFooter({ leftButtons, rightButtons, className }: ButtonFooterProps) {
return (
<div
className={cn(
// Normal flow footer row
'flex items-center justify-between',
// Fixed footer height with safe area padding for mobile
'h-12 px-3 pr-4',
// No visual separator; seamless with editor area
// Ensure interactions work normally
className
)}
>
{/* Left side buttons (Attach, Record, etc.) */}
<div className="flex items-center gap-2">{leftButtons}</div>
{/* Right side buttons (Send) */}
<div className="flex items-center gap-2">{rightButtons}</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface ChatInputContainerProps {
children: React.ReactNode;
className?: string;
}
export function ChatInputContainer({ children, className }: ChatInputContainerProps) {
return (
<div
className={cn(
'relative',
'w-full',
// Vertical layout: editor (scrollable) + footer (fixed)
// Allow overlays (e.g., slash autocomplete) to escape the editor area
'flex flex-col overflow-visible',
'max-h-[max(35svh,5rem)]', // commonly used responsive height
'border border-border/30',
// Opaque background to prevent underlying text/blur artifacts
'bg-background',
'rounded-3xl',
'shadow-lg hover:shadow-xl',
'transition-all duration-200',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Mic, StopCircle } from 'lucide-react';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { cn } from '@/lib/utils';
interface RecordButtonProps {
isRecording: boolean;
onToggleRecording: () => void;
className?: string;
disabled?: boolean;
/** Use lg breakpoint instead of md for responsive text */
useLargeBreakpoint?: boolean;
}
export function RecordButton({
isRecording,
onToggleRecording,
className,
disabled,
useLargeBreakpoint = false,
}: RecordButtonProps) {
const btn = (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (!disabled) onToggleRecording();
}}
className={cn(
'h-8 px-2 text-sm rounded-full',
useLargeBreakpoint ? 'lg:px-3' : 'md:px-3',
disabled
? 'opacity-50 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground',
className
)}
aria-label={isRecording ? 'Stop recording' : 'Record audio'}
aria-disabled={disabled ? true : undefined}
>
{isRecording ? (
<StopCircle
className={cn(
'h-3 w-3 text-red-500',
useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5'
)}
/>
) : (
<Mic className={cn('h-3 w-3', useLargeBreakpoint ? 'lg:mr-1.5' : 'md:mr-1.5')} />
)}
<span className={cn('hidden', useLargeBreakpoint ? 'lg:inline' : 'md:inline')}>
{isRecording ? 'Stop' : 'Record'}
</span>
</Button>
);
return disabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent side="bottom">Unsupported for this model</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
btn
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Zap } from 'lucide-react';
import { Switch } from '../ui/switch';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../ui/tooltip';
interface StreamToggleProps {
isStreaming: boolean;
onStreamingChange: (enabled: boolean) => void;
className?: string;
}
export function StreamToggle({ isStreaming, onStreamingChange, className }: StreamToggleProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={`flex items-center gap-1.5 cursor-pointer ${className || ''}`}>
<Zap
className={`h-3 w-3 ${isStreaming ? 'text-blue-500' : 'text-muted-foreground'}`}
/>
<Switch
checked={isStreaming}
onCheckedChange={onStreamingChange}
className="scale-75"
aria-label="Toggle streaming"
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isStreaming ? 'Streaming enabled' : 'Streaming disabled'}</p>
<p className="text-xs opacity-75">
{isStreaming
? 'Responses will stream in real-time'
: 'Responses will arrive all at once'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,5 @@
export { ChatInputContainer } from './ChatInputContainer';
export { ButtonFooter } from './ButtonFooter';
export { StreamToggle } from './StreamToggle';
export { AttachButton } from './AttachButton';
export { RecordButton } from './RecordButton';

View File

@@ -0,0 +1,395 @@
/**
* CodePreview Component
*
* Displays code with syntax highlighting, scrollable preview,
* and option to expand to full-screen Monaco editor.
*/
import { useState, useCallback, useEffect, lazy, Suspense } from 'react';
import { Copy, Check, Maximize2, X, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from './hooks/useTheme';
import hljs from 'highlight.js/lib/core';
// Register common languages
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import json from 'highlight.js/lib/languages/json';
import python from 'highlight.js/lib/languages/python';
import bash from 'highlight.js/lib/languages/bash';
import xml from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import yaml from 'highlight.js/lib/languages/yaml';
import markdown from 'highlight.js/lib/languages/markdown';
import sql from 'highlight.js/lib/languages/sql';
import go from 'highlight.js/lib/languages/go';
import rust from 'highlight.js/lib/languages/rust';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('tsx', typescript);
hljs.registerLanguage('jsx', javascript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('python', python);
hljs.registerLanguage('py', python);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('shell', bash);
hljs.registerLanguage('html', xml);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('css', css);
hljs.registerLanguage('yaml', yaml);
hljs.registerLanguage('yml', yaml);
hljs.registerLanguage('markdown', markdown);
hljs.registerLanguage('md', markdown);
hljs.registerLanguage('sql', sql);
hljs.registerLanguage('go', go);
hljs.registerLanguage('rust', rust);
hljs.registerLanguage('rs', rust);
// Lazy load Monaco for full editor view
const Editor = lazy(() => import('@monaco-editor/react'));
interface CodePreviewProps {
/** Code content to display */
content: string;
/** File path for language detection and display */
filePath?: string;
/** Override detected language */
language?: string;
/** Maximum lines before showing "show more" (default: 10) */
maxLines?: number;
/** Whether to show line numbers (default: true) */
showLineNumbers?: boolean;
/** Maximum height in pixels for the preview (default: 200) */
maxHeight?: number;
/** Optional title/label */
title?: string;
/** Show icon before title */
showIcon?: boolean;
/** Show header with title/actions (default: true) */
showHeader?: boolean;
}
// Map file extensions to hljs/monaco languages
const EXT_TO_LANG: Record<string, string> = {
js: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
mts: 'typescript',
cts: 'typescript',
json: 'json',
py: 'python',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
html: 'html',
htm: 'html',
xml: 'xml',
svg: 'xml',
css: 'css',
scss: 'css',
less: 'css',
yaml: 'yaml',
yml: 'yaml',
md: 'markdown',
mdx: 'markdown',
sql: 'sql',
go: 'go',
rs: 'rust',
toml: 'yaml',
ini: 'yaml',
env: 'bash',
dockerfile: 'bash',
makefile: 'bash',
};
function getLanguageFromPath(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const filename = filePath.split('/').pop()?.toLowerCase() || '';
// Check special filenames
if (filename === 'dockerfile') return 'bash';
if (filename === 'makefile') return 'bash';
if (filename.startsWith('.env')) return 'bash';
return EXT_TO_LANG[ext] || 'plaintext';
}
function getShortPath(path: string): string {
const parts = path.split('/').filter(Boolean);
if (parts.length <= 2) return path;
return `.../${parts.slice(-2).join('/')}`;
}
/**
* Escape HTML entities to prevent XSS when using dangerouslySetInnerHTML
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export function CodePreview({
content,
filePath,
language: overrideLanguage,
maxLines = 10,
showLineNumbers = true,
maxHeight = 200,
title,
showIcon = true,
showHeader = true,
}: CodePreviewProps) {
const [showAll, setShowAll] = useState(false);
const [showFullScreen, setShowFullScreen] = useState(false);
const [copied, setCopied] = useState(false);
const { theme } = useTheme();
const language = overrideLanguage || (filePath ? getLanguageFromPath(filePath) : 'plaintext');
const lines = content.split('\n');
const shouldTruncate = lines.length > maxLines && !showAll;
const displayContent = shouldTruncate ? lines.slice(0, maxLines).join('\n') : content;
// Apply HTML escaping before syntax highlighting to prevent XSS
let highlightedContent: string;
try {
if (language !== 'plaintext') {
// Escape HTML entities first, then highlight the escaped content
const escaped = escapeHtml(displayContent);
const result = hljs.highlight(escaped, { language, ignoreIllegals: true });
highlightedContent = result.value;
} else {
// Plaintext - escape HTML entities
highlightedContent = escapeHtml(displayContent);
}
} catch {
// Highlight failed - escape HTML entities for safety
highlightedContent = escapeHtml(displayContent);
}
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [content]);
// Handle escape key to close full screen modal
useEffect(() => {
if (!showFullScreen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowFullScreen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showFullScreen]);
const displayTitle = title || (filePath ? getShortPath(filePath) : undefined);
return (
<>
<div className="space-y-1">
{/* Header */}
{showHeader && (displayTitle || filePath) && (
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5 text-muted-foreground">
{showIcon && <FileText className="h-3 w-3" />}
<span className="font-mono">{displayTitle}</span>
<span className="text-[10px]">{lines.length} lines</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleCopy}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
title="Copy to clipboard"
aria-label="Copy code to clipboard"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
<button
onClick={() => setShowFullScreen(true)}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
title="Open full view"
aria-label="Open code in full screen"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
</div>
)}
{/* Code preview */}
<div
className="bg-zinc-100 dark:bg-zinc-950 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800"
style={{ maxHeight: showAll ? undefined : maxHeight }}
>
<div
className={cn('overflow-auto', showAll ? 'max-h-[400px]' : '')}
style={{ maxHeight: showAll ? 400 : maxHeight - 2 }}
>
<pre className="p-2 text-[11px] font-mono leading-relaxed">
{showLineNumbers ? (
<code>
{(showAll ? content : displayContent)
.split('\n')
.map((line, i) => (
<div key={i} className="flex">
<span className="w-8 pr-2 text-right text-zinc-400 dark:text-zinc-600 select-none flex-shrink-0">
{i + 1}
</span>
<span
className="text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-all flex-1"
dangerouslySetInnerHTML={{
__html: (() => {
const lineContent = line || ' ';
// Always escape first to prevent XSS
const escaped = escapeHtml(lineContent);
try {
if (language !== 'plaintext') {
return hljs.highlight(escaped, {
language,
ignoreIllegals: true,
}).value;
}
} catch {
// fallback - already escaped above
}
return escaped;
})(),
}}
/>
</div>
))}
</code>
) : (
<code
className="text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-all"
dangerouslySetInnerHTML={{ __html: highlightedContent }}
/>
)}
</pre>
</div>
{/* Show more button */}
{shouldTruncate && (
<button
onClick={() => setShowAll(true)}
className="w-full py-1 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
>
Show {lines.length - maxLines} more lines...
</button>
)}
{showAll && lines.length > maxLines && (
<button
onClick={() => setShowAll(false)}
className="w-full py-1 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-700 transition-colors"
>
Show less
</button>
)}
</div>
</div>
{/* Full screen modal with Monaco */}
{showFullScreen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Code preview"
>
<div className="relative w-[90vw] h-[85vh] bg-white dark:bg-zinc-900 rounded-lg shadow-2xl flex flex-col overflow-hidden border border-zinc-200 dark:border-zinc-700">
{/* Modal header */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-100 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<FileText className="h-4 w-4" />
<span className="font-mono">{filePath || 'Code'}</span>
<span className="text-zinc-500 dark:text-zinc-500">
({lines.length} lines)
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded transition-colors"
aria-label={copied ? 'Code copied' : 'Copy code to clipboard'}
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-green-500" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Copy
</>
)}
</button>
<button
onClick={() => setShowFullScreen(false)}
className="p-1 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded transition-colors"
aria-label="Close full screen view"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Monaco editor */}
<div className="flex-1">
<Suspense
fallback={
<div className="flex items-center justify-center h-full text-zinc-500">
Loading editor...
</div>
}
>
<Editor
height="100%"
language={language === 'plaintext' ? undefined : language}
value={content}
theme={theme === 'dark' ? 'vs-dark' : 'light'}
options={{
readOnly: true,
minimap: { enabled: true },
scrollBeyondLastLine: false,
fontSize: 13,
lineNumbers: 'on',
renderLineHighlight: 'all',
folding: true,
automaticLayout: true,
wordWrap: 'on',
}}
/>
</Suspense>
</div>
</div>
{/* Click outside to close */}
<div
className="absolute inset-0 -z-10"
onClick={() => setShowFullScreen(false)}
/>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,496 @@
import React, { useState, useEffect } from 'react';
import type {
McpServerConfig,
StdioServerConfig,
SseServerConfig,
HttpServerConfig,
} from '@dexto/core';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { KeyValueEditor } from './ui/key-value-editor';
import { Checkbox } from './ui/checkbox';
import { useAddServer } from './hooks/useServers';
interface ConnectServerModalProps {
isOpen: boolean;
onClose: () => void;
onServerConnected?: () => void;
initialName?: string;
initialConfig?:
| Partial<StdioServerConfig>
| Partial<SseServerConfig>
| Partial<HttpServerConfig>;
lockName?: boolean;
}
export default function ConnectServerModal({
isOpen,
onClose,
onServerConnected,
initialName,
initialConfig,
lockName,
}: ConnectServerModalProps) {
const addServerMutation = useAddServer();
const [serverName, setServerName] = useState('');
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'http'>('stdio');
const [command, setCommand] = useState('');
const [args, setArgs] = useState('');
const [url, setUrl] = useState('');
const [headerPairs, setHeaderPairs] = useState<
Array<{ key: string; value: string; id: string }>
>([]);
const [envPairs, setEnvPairs] = useState<Array<{ key: string; value: string; id: string }>>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [persistToAgent, setPersistToAgent] = useState(false);
// Helper function to convert header pairs to record
const headersToRecord = (
pairs: Array<{ key: string; value: string; id: string }>
): Record<string, string> => {
const headers: Record<string, string> = {};
pairs.forEach((pair) => {
if (pair.key.trim() && pair.value.trim()) {
headers[pair.key.trim()] = pair.value.trim();
}
});
return headers;
};
// Helper function to convert env pairs to record
const envToRecord = (
pairs: Array<{ key: string; value: string; id: string }>
): Record<string, string> => {
const env: Record<string, string> = {};
for (const { key, value } of pairs) {
const k = key.trim();
const v = value.trim();
if (k && v) env[k] = v;
}
return env;
};
// Helper function to mask sensitive environment values for logging
const maskSensitiveEnv = (env: Record<string, string>): Record<string, string> => {
const sensitiveKeys = ['api_key', 'secret', 'token', 'password', 'key'];
const masked: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const isSensitive = sensitiveKeys.some((sk) => key.toLowerCase().includes(sk));
masked[key] = isSensitive ? '***masked***' : value;
}
return masked;
};
// Helper to mask sensitive headers for logging
const maskSensitiveHeaders = (headers: Record<string, string>): Record<string, string> => {
const sensitive = [
'authorization',
'proxy-authorization',
'api-key',
'x-api-key',
'token',
'cookie',
'set-cookie',
];
const masked: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
const key = k.toLowerCase();
const isSensitive = sensitive.some((s) => key === s || key.includes(s));
masked[k] = isSensitive ? '***masked***' : v;
}
return masked;
};
useEffect(() => {
if (!isOpen) {
const timer = setTimeout(() => {
setServerName('');
setServerType('stdio');
setCommand('');
setArgs('');
setUrl('');
setHeaderPairs([]);
setEnvPairs([]);
setError(null);
setIsSubmitting(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Apply initialName/initialConfig when they change and modal opens
useEffect(() => {
if (!isOpen) return;
setServerName(initialName ?? '');
const type = initialConfig?.type ?? 'stdio';
setServerType(type);
if (type === 'stdio') {
const std = (initialConfig ?? {}) as Partial<StdioServerConfig>;
setCommand(typeof std.command === 'string' ? std.command : '');
setArgs(Array.isArray(std.args) ? std.args.join(', ') : '');
const envEntries = Object.entries(std.env ?? {});
setEnvPairs(
envEntries.map(([key, value], idx) => ({
key,
value: String(value ?? ''),
id: `env-${idx}`,
}))
);
// clear URL/header state
setUrl('');
setHeaderPairs([]);
} else {
const net = (initialConfig ?? {}) as Partial<SseServerConfig | HttpServerConfig>;
setUrl(typeof net.url === 'string' ? net.url : '');
const hdrEntries = Object.entries(net.headers ?? {});
setHeaderPairs(
hdrEntries.map(([key, value], idx) => ({
key,
value: String(value ?? ''),
id: `hdr-${idx}`,
}))
);
// clear stdio state
setCommand('');
setArgs('');
setEnvPairs([]);
}
}, [isOpen, initialName, initialConfig]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
// Validate server name
if (!serverName.trim()) {
setError('Server name is required.');
setIsSubmitting(false);
return;
}
// Validate required fields based on server type
if (serverType === 'stdio') {
if (!command.trim()) {
setError('Command is required for stdio servers.');
setIsSubmitting(false);
return;
}
// Validate environment variables
const requiredKeys = envPairs.map((p) => p.key.trim()).filter(Boolean);
if (requiredKeys.length) {
const dupes = requiredKeys.filter((k, i) => requiredKeys.indexOf(k) !== i);
if (dupes.length) {
setError(
`Duplicate environment variables: ${Array.from(new Set(dupes)).join(', ')}`
);
setIsSubmitting(false);
return;
}
const missing = envPairs
.filter((p) => p.key.trim() && !p.value.trim())
.map((p) => p.key.trim());
if (missing.length) {
setError(`Please set required environment variables: ${missing.join(', ')}`);
setIsSubmitting(false);
return;
}
}
} else {
if (!url.trim()) {
setError(`URL is required for ${serverType.toUpperCase()} servers.`);
setIsSubmitting(false);
return;
}
try {
new URL(url.trim());
} catch (_) {
setError(`Invalid URL format for ${serverType.toUpperCase()} server.`);
setIsSubmitting(false);
return;
}
}
// Create config after all validation passes
let config: McpServerConfig;
if (serverType === 'stdio') {
config = {
type: 'stdio',
command: command.trim(),
args: args
.split(',')
.map((s) => s.trim())
.filter(Boolean),
env: envToRecord(envPairs),
timeout: 30000,
connectionMode: 'lenient',
};
} else if (serverType === 'sse') {
config = {
type: 'sse',
url: url.trim(),
headers: headerPairs.length ? headersToRecord(headerPairs) : {},
timeout: 30000,
connectionMode: 'lenient',
};
} else {
config = {
type: 'http',
url: url.trim(),
headers: headerPairs.length ? headersToRecord(headerPairs) : {},
timeout: 30000,
connectionMode: 'lenient',
};
}
try {
await addServerMutation.mutateAsync({
name: serverName.trim(),
config,
persistToAgent,
});
if (import.meta.env.DEV) {
// Create a safe version for logging with masked sensitive values
const safeConfig = { ...config };
if (safeConfig.type === 'stdio' && safeConfig.env) {
safeConfig.env = maskSensitiveEnv(safeConfig.env);
} else if (
(safeConfig.type === 'sse' || safeConfig.type === 'http') &&
safeConfig.headers
) {
safeConfig.headers = maskSensitiveHeaders(safeConfig.headers);
}
console.debug(
`[ConnectServerModal.handleSubmit] Connected server with config: ${JSON.stringify(safeConfig)}`
);
}
onServerConnected?.();
onClose();
} catch (err: unknown) {
let message = 'Failed to connect server';
if (err instanceof Error) {
message = err.message || message;
} else if (typeof err === 'string') {
message = err;
}
addServerMutation.reset();
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Connect New MCP Server</DialogTitle>
<DialogDescription>
Configure connection details for a new MCP server (stdio, SSE, or HTTP).
</DialogDescription>
</DialogHeader>
<form id="connectServerForm" onSubmit={handleSubmit} className="grid gap-4 py-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="serverName" className="text-right">
Server Name
</Label>
<Input
id="serverName"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
className="col-span-3"
placeholder="e.g., My Local Tools"
required
disabled={isSubmitting || !!lockName}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="serverType" className="text-right">
Server Type
</Label>
<Select
value={serverType}
onValueChange={(value: 'stdio' | 'sse' | 'http') =>
setServerType(value)
}
disabled={isSubmitting}
>
<SelectTrigger id="serverType" className="col-span-3">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">stdio</SelectItem>
<SelectItem value="sse">sse</SelectItem>
<SelectItem value="http">http</SelectItem>
</SelectContent>
</Select>
</div>
{serverType === 'stdio' ? (
<>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="command" className="text-right">
Command
</Label>
<Input
id="command"
value={command}
onChange={(e) => setCommand(e.target.value)}
className="col-span-3"
placeholder="e.g., /path/to/executable or python"
required
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="args" className="text-right">
Arguments
</Label>
<Input
id="args"
value={args}
onChange={(e) => setArgs(e.target.value)}
className="col-span-3"
placeholder="Comma-separated, e.g., -m,script.py,--port,8080"
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right mt-2">Environment</Label>
<div className="col-span-3">
<KeyValueEditor
pairs={envPairs}
onChange={setEnvPairs}
placeholder={{
key: 'API_KEY',
value: 'your-secret-key',
}}
disabled={isSubmitting}
keyLabel="Variable"
valueLabel="Value"
/>
</div>
</div>
</>
) : serverType === 'sse' ? (
<>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="url" className="text-right">
URL
</Label>
<Input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="col-span-3"
placeholder="e.g., http://localhost:8000/events"
required
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right mt-2">Headers</Label>
<div className="col-span-3">
<KeyValueEditor
pairs={headerPairs}
onChange={setHeaderPairs}
placeholder={{
key: 'Authorization',
value: 'Bearer your-token',
}}
disabled={isSubmitting}
keyLabel="Header"
valueLabel="Value"
/>
</div>
</div>
</>
) : (
<>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="url" className="text-right">
URL
</Label>
<Input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="col-span-3"
placeholder="e.g., http://localhost:8080"
required
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label className="text-right mt-2">Headers</Label>
<div className="col-span-3">
<KeyValueEditor
pairs={headerPairs}
onChange={setHeaderPairs}
placeholder={{
key: 'Authorization',
value: 'Bearer your-token',
}}
disabled={isSubmitting}
keyLabel="Header"
valueLabel="Value"
/>
</div>
</div>
</>
)}
{/* Persist to Agent Checkbox */}
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id="persistToAgent"
checked={persistToAgent}
onCheckedChange={(checked) => setPersistToAgent(checked === true)}
disabled={isSubmitting}
/>
<Label
htmlFor="persistToAgent"
className="text-sm font-normal cursor-pointer"
>
Save to agent configuration file
</Label>
</div>
</form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</DialogClose>
<Button type="submit" form="connectServerForm" disabled={isSubmitting}>
{isSubmitting ? 'Connecting...' : 'Connect'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { useCreateMemory } from './hooks/useMemories';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { AlertCircle, Loader2 } from 'lucide-react';
import { Alert, AlertDescription } from './ui/alert';
interface CreateMemoryModalProps {
open: boolean;
onClose: () => void;
}
export default function CreateMemoryModal({ open, onClose }: CreateMemoryModalProps) {
const [content, setContent] = useState('');
const [tags, setTags] = useState('');
const createMemoryMutation = useCreateMemory();
const handleSuccess = () => {
setContent('');
setTags('');
onClose();
};
const handleSubmit = async () => {
if (!content.trim()) return;
const payload = {
content: content.trim(),
...(tags.trim() && {
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}),
metadata: { source: 'user' as const },
};
createMemoryMutation.mutate(payload, {
onSuccess: handleSuccess,
});
};
const handleClose = () => {
if (!createMemoryMutation.isPending) {
setContent('');
setTags('');
createMemoryMutation.reset();
onClose();
}
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleClose();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Memory</DialogTitle>
<DialogDescription>
Memories are automatically included in every conversation to help Dexto
remember your preferences and important information.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{createMemoryMutation.error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{createMemoryMutation.error instanceof Error
? createMemoryMutation.error.message
: 'Failed to create memory'}
</AlertDescription>
</Alert>
)}
<div className="grid gap-2">
<Label htmlFor="memory-content">Memory Content *</Label>
<Textarea
id="memory-content"
placeholder="e.g., I prefer concise responses without explanations"
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[100px] resize-none"
disabled={createMemoryMutation.isPending}
autoFocus
/>
<p className="text-sm text-muted-foreground">
{content.length} / 10,000 characters
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="memory-tags">Tags (optional)</Label>
<Input
id="memory-tags"
placeholder="e.g., preferences, work, coding-style"
value={tags}
onChange={(e) => setTags(e.target.value)}
disabled={createMemoryMutation.isPending}
/>
<p className="text-sm text-muted-foreground">
Comma-separated tags for organizing memories
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={createMemoryMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={createMemoryMutation.isPending || !content.trim()}
>
{createMemoryMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create Memory'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { useCreatePrompt } from './hooks/usePrompts';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Label } from './ui/label';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { Loader2, Trash2, Upload, FileText } from 'lucide-react';
interface CreatePromptModalProps {
open: boolean;
onClose: () => void;
onCreated: (prompt: { name: string }) => void;
}
interface ResourcePayload {
data: string;
mimeType: string;
filename?: string;
}
export default function CreatePromptModal({ open, onClose, onCreated }: CreatePromptModalProps) {
const [name, setName] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [content, setContent] = useState('');
const [resource, setResource] = useState<ResourcePayload | null>(null);
const [resourcePreview, setResourcePreview] = useState<string | null>(null);
const [resourceName, setResourceName] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const createPromptMutation = useCreatePrompt();
useEffect(() => {
if (open) {
setName('');
setTitle('');
setDescription('');
setContent('');
setResource(null);
setResourcePreview(null);
setResourceName(null);
setIsDragOver(false);
createPromptMutation.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleFile = async (file: File) => {
try {
const data = await readFileAsDataUrl(file);
setResource({
data,
mimeType: file.type || 'application/octet-stream',
filename: file.name,
});
setResourcePreview(data);
setResourceName(file.name);
} catch (error) {
console.error('Failed to read file:', error);
}
};
const handleResourceChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setResource(null);
setResourcePreview(null);
setResourceName(null);
return;
}
await handleFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
await handleFile(files[0]);
}
};
const removeResource = () => {
setResource(null);
setResourcePreview(null);
setResourceName(null);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!name.trim() || !content.trim()) {
return;
}
const payload = {
name: name.trim(),
title: title.trim() || undefined,
description: description.trim() || undefined,
content,
resource: resource || undefined,
};
createPromptMutation.mutate(payload, {
onSuccess: (data) => {
if (data?.prompt) {
onCreated({ name: data.prompt.name });
}
onClose();
},
});
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Create Custom Prompt</DialogTitle>
<DialogDescription>
Define reusable prompt text and optionally attach a supporting resource
file.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{createPromptMutation.error && (
<Alert variant="destructive">
<AlertDescription>
{createPromptMutation.error instanceof Error
? createPromptMutation.error.message
: 'Failed to create prompt'}
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="prompt-name">Prompt Name</Label>
<Input
id="prompt-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="research-summary"
required
/>
<p className="text-[11px] text-muted-foreground">
Use lowercase letters, numbers, or hyphens. This becomes your
/prompt command.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-title">Title</Label>
<Input
id="prompt-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Research Summary"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-description">Description</Label>
<Input
id="prompt-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Summarize research papers with key findings"
/>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-content">Prompt Content</Label>
<Textarea
id="prompt-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write the instructions for this prompt..."
className="min-h-[160px]"
required
/>
</div>
<div className="space-y-3">
<Label>Attach resource (optional)</Label>
{resourcePreview ? (
<div className="flex items-center justify-between rounded-lg border border-dashed border-border/60 bg-muted/40 px-4 py-3">
<div className="flex items-center text-sm">
<FileText className="h-4 w-4 mr-2 text-muted-foreground" />
<Badge variant="secondary" className="mr-2">
Resource
</Badge>
{resourceName || 'Attached file'}
</div>
<Button variant="ghost" size="sm" onClick={removeResource}>
<Trash2 className="h-4 w-4 mr-1" /> Remove
</Button>
</div>
) : (
<div
className={`relative rounded-lg border-2 border-dashed transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border/60 hover:border-border/80'
} px-6 py-8 text-center`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
onChange={handleResourceChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
accept="*/*"
/>
<div className="flex flex-col items-center justify-center space-y-2">
<Upload
className={`h-8 w-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="text-sm">
<span className="font-medium">
{isDragOver ? 'Drop file here' : 'Click to upload'}
</span>
<span className="text-muted-foreground">
{' '}
or drag and drop
</span>
</div>
<p className="text-xs text-muted-foreground">
Any file type supported
</p>
</div>
</div>
)}
<p className="text-[11px] text-muted-foreground">
The resource will be stored securely and referenced when this prompt is
used.
</p>
</div>
<DialogFooter className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={createPromptMutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={createPromptMutation.isPending}>
{createPromptMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...
</>
) : (
'Save Prompt'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === 'string') {
resolve(result);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronUp, X } from 'lucide-react';
import { type ErrorMessage } from '@/lib/stores/chatStore';
import type { Issue } from '@dexto/core';
import { CopyButton } from './ui/copy-button';
import { SpeakButton } from './ui/speak-button';
interface ErrorBannerProps {
error: ErrorMessage;
onDismiss: () => void;
}
export default function ErrorBanner({ error, onDismiss }: ErrorBannerProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Extract the actual detailed validation issues from the hierarchical structure
// The server sends: hierarchicalError.issues[0].context.detailedIssues = [actual validation issues]
// TODO: Update this to just print the entire error object
const firstIssue = error.detailedIssues?.[0];
const detailedIssues =
firstIssue?.context &&
typeof firstIssue.context === 'object' &&
'detailedIssues' in firstIssue.context
? (firstIssue.context as { detailedIssues: Issue[] }).detailedIssues
: [];
// Get the text to copy - include both top-level and detailed messages with full context
const fullErrorText =
detailedIssues.length > 0
? `${error.message}\n\nDetails:\n${detailedIssues
.map((issue: Issue) => {
let text = issue.message;
if (issue.context) {
const contextStr =
typeof issue.context === 'string'
? issue.context
: JSON.stringify(issue.context, null, 2);
text += `\nContext: ${contextStr}`;
}
return text;
})
.join('\n\n')}`
: error.message;
return (
<div className="w-full rounded-lg p-4 mb-4 border shadow-sm bg-destructive/10 border-destructive/40">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-destructive">Error</h3>
{error.context && (
<span className="text-xs bg-destructive/15 text-destructive px-2 py-0.5 rounded-full">
{error.context}
</span>
)}
</div>
<div className="flex items-center gap-1">
{detailedIssues.length > 0 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-destructive/15 rounded text-destructive"
title={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
)}
<CopyButton
value={fullErrorText}
tooltip="Copy error"
className="p-1 hover:bg-destructive/15 rounded text-destructive"
size={16}
/>
<SpeakButton
value={fullErrorText}
tooltip="Read error"
className="p-1 hover:bg-destructive/15 rounded text-destructive"
/>
<button
onClick={onDismiss}
className="p-1 hover:bg-destructive/15 rounded text-destructive"
title="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Main error message */}
<div className="mt-2 text-sm text-destructive">{error.message}</div>
{isExpanded && detailedIssues.length > 0 && (
<div className="mt-3">
<div className="text-xs text-destructive bg-destructive/10 p-3 rounded border border-destructive/30 overflow-auto max-h-60">
{detailedIssues.map((issue: Issue, index: number) => (
<div key={index} className="mb-2 last:mb-0">
<div className="font-medium">{issue.message}</div>
{issue.context != null && (
<div className="text-destructive/70 mt-1">
{typeof issue.context === 'string'
? issue.context
: JSON.stringify(issue.context, null, 2)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useDebounce } from 'use-debounce';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSearchMessages, type SearchResult } from './hooks/useSearch';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Search, MessageSquare, User, Bot, Settings, ChevronRight, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from './ui/badge';
interface GlobalSearchModalProps {
isOpen: boolean;
onClose: () => void;
onNavigateToSession: (sessionId: string, messageIndex?: number) => void;
}
export default function GlobalSearchModal({
isOpen,
onClose,
onNavigateToSession,
}: GlobalSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery] = useDebounce(searchQuery, 300);
const [selectedIndex, setSelectedIndex] = useState(0);
// Use TanStack Query for search with debouncing
const { data, isLoading, error } = useSearchMessages(debouncedQuery, undefined, 10, isOpen);
const results = data?.results || [];
const searchError = error?.message ?? null;
// Clamp selectedIndex when results change to prevent out-of-bounds selection
useEffect(() => {
if (selectedIndex >= results.length && results.length > 0) {
setSelectedIndex(results.length - 1);
} else if (results.length === 0) {
setSelectedIndex(0);
}
}, [results.length, selectedIndex]);
const handleResultClick = useCallback(
(result: SearchResult) => {
onNavigateToSession(result.sessionId, result.messageIndex);
onClose();
},
[onNavigateToSession, onClose]
);
// Reset when modal opens/closes
useEffect(() => {
if (isOpen) {
setSearchQuery('');
setSelectedIndex(0);
}
}, [isOpen]);
// Keyboard navigation (using react-hotkeys-hook)
// ArrowDown to navigate down in results
useHotkeys(
'down',
() => {
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
},
{ enabled: isOpen, preventDefault: true },
[isOpen, results.length]
);
// ArrowUp to navigate up in results
useHotkeys(
'up',
() => {
setSelectedIndex((prev) => Math.max(prev - 1, 0));
},
{ enabled: isOpen, preventDefault: true },
[isOpen]
);
// Enter to select current result
useHotkeys(
'enter',
() => {
if (results[selectedIndex]) {
handleResultClick(results[selectedIndex]);
setSelectedIndex(0);
}
},
{ enabled: isOpen, preventDefault: true },
[isOpen, results, selectedIndex, handleResultClick]
);
// Escape to close modal
useHotkeys(
'escape',
() => {
onClose();
},
{ enabled: isOpen, preventDefault: true },
[isOpen, onClose]
);
const getRoleIcon = (role: string) => {
switch (role) {
case 'user':
return <User className="w-4 h-4" />;
case 'assistant':
return <Bot className="w-4 h-4" />;
case 'system':
return <Settings className="w-4 h-4" />;
default:
return <MessageSquare className="w-4 h-4" />;
}
};
const getRoleColor = (role: string) => {
switch (role) {
case 'user':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'assistant':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'system':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200';
}
};
const highlightText = (text: string, query: string) => {
if (!query) return text;
const escapedQuery = query.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark
key={index}
className="bg-yellow-200 dark:bg-yellow-800 font-medium rounded px-1"
>
{part}
</mark>
) : (
part
)
);
};
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 bg-black/10 backdrop-blur-[2px]" onClick={onClose} />
{/* Search popover */}
<div className="fixed left-1/2 top-[15%] -translate-x-1/2 z-50 w-full max-w-2xl bg-popover/70 backdrop-blur-xl border border-border/30 rounded-xl shadow-2xl overflow-hidden">
<div className="flex flex-col max-h-[70vh]">
{/* Search Header */}
<div className="p-4 border-b border-border/30">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<Input
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 text-lg border-0 shadow-none focus-visible:ring-0 bg-transparent"
autoFocus
/>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin mr-3" />
<span className="text-muted-foreground">Searching...</span>
</div>
) : error ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto mb-4 text-destructive opacity-50" />
<p className="text-destructive font-medium">Search Error</p>
<p className="text-sm text-muted-foreground mt-2">
{searchError}
</p>
<p className="text-xs text-muted-foreground mt-2">
Try again or check your connection.
</p>
</div>
</div>
) : (
<ScrollArea className="h-full max-h-[calc(70vh-80px)]">
<div className="p-2">
{results.length > 0 ? (
<div className="space-y-1">
{results.map((result, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors',
index === selectedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50'
)}
onClick={() => handleResultClick(result)}
>
<div className="flex-shrink-0 mt-1">
<Badge
className={cn(
'text-xs',
getRoleColor(result.message.role)
)}
>
{getRoleIcon(result.message.role)}
</Badge>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
{result.sessionId.length > 20
? `${result.sessionId.slice(0, 20)}...`
: result.sessionId}
</span>
<span className="text-xs text-muted-foreground">
{result.message.role}
</span>
</div>
<div className="text-sm text-muted-foreground line-clamp-2">
{highlightText(
result.context,
debouncedQuery
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0 mt-1" />
</div>
))}
</div>
) : debouncedQuery ? (
<div className="text-center py-12">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
No messages found matching your search.
</p>
<p className="text-sm text-muted-foreground mt-2">
Try different keywords.
</p>
</div>
) : (
<div className="text-center py-12">
<Search className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
Start typing to search your conversations.
</p>
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
</kbd>
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
</kbd>
<span>to navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
Enter
</kbd>
<span>to select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted/50 rounded text-xs">
Esc
</kbd>
<span>to close</span>
</div>
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,326 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import { AlertTriangle, Wrench } from 'lucide-react';
import type { ApprovalEvent } from './ToolConfirmationHandler';
import type { JSONSchema7 } from 'json-schema';
import { ApprovalType } from '@dexto/core';
interface InlineApprovalCardProps {
approval: ApprovalEvent;
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
onDeny: () => void;
}
export function InlineApprovalCard({ approval, onApprove, onDeny }: InlineApprovalCardProps) {
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [rememberChoice, setRememberChoice] = useState(false);
// Update form field value
const updateFormField = (fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
if (formErrors[fieldName]) {
setFormErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
}
};
// Render form field based on JSON Schema field type
const renderFormField = (fieldName: string, fieldSchema: JSONSchema7, isRequired: boolean) => {
const fieldType = fieldSchema.type || 'string';
const fieldValue = formData[fieldName];
const hasError = !!formErrors[fieldName];
// Use title if available, fallback to fieldName
const label = fieldSchema.title || fieldName;
if (fieldType === 'boolean') {
return (
<div key={fieldName} className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={fieldName}
checked={fieldValue === true}
onCheckedChange={(checked) =>
updateFormField(fieldName, checked === true)
}
/>
<label htmlFor={fieldName} className="text-sm font-medium">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
{fieldSchema.description && (
<p className="text-xs text-muted-foreground ml-6">
{fieldSchema.description}
</p>
)}
</div>
);
}
if (fieldType === 'number' || fieldType === 'integer') {
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-sm font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
)}
<input
id={fieldName}
type="number"
step={fieldType === 'integer' ? '1' : 'any'}
value={typeof fieldValue === 'number' ? fieldValue : ''}
onChange={(e) => {
const raw = e.target.value;
const nextValue = raw === '' ? undefined : Number(raw);
updateFormField(fieldName, nextValue);
}}
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
hasError ? 'border-red-500' : 'border-border'
}`}
placeholder={isRequired ? 'Required' : 'Optional'}
/>
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
</div>
);
}
if (fieldSchema.enum && Array.isArray(fieldSchema.enum)) {
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-sm font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
)}
<select
id={fieldName}
value={
fieldValue !== undefined && fieldValue !== null
? String(fieldValue)
: ''
}
onChange={(e) => {
const selected = e.target.value;
if (selected === '') {
updateFormField(fieldName, undefined);
return;
}
const matched = (fieldSchema.enum as unknown[])?.find(
(option) => String(option) === selected
);
updateFormField(fieldName, matched ?? selected);
}}
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
hasError ? 'border-red-500' : 'border-border'
}`}
>
<option value="">Select an option...</option>
{(fieldSchema.enum as unknown[])?.map((option) => (
<option key={String(option)} value={String(option)}>
{String(option)}
</option>
))}
</select>
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
</div>
);
}
// Default to string input
return (
<div key={fieldName} className="space-y-1">
<label htmlFor={fieldName} className="text-sm font-medium block">
{label}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
{fieldSchema.description && (
<p className="text-xs text-muted-foreground">{fieldSchema.description}</p>
)}
<input
id={fieldName}
type="text"
value={
fieldValue !== undefined &&
fieldValue !== null &&
typeof fieldValue !== 'object'
? String(fieldValue)
: ''
}
onChange={(e) => updateFormField(fieldName, e.target.value)}
className={`w-full px-3 py-2 border rounded-md text-sm bg-background ${
hasError ? 'border-red-500' : 'border-border'
}`}
placeholder={isRequired ? 'Required' : 'Optional'}
/>
{hasError && <p className="text-xs text-red-500">{formErrors[fieldName]}</p>}
</div>
);
};
const handleApprove = () => {
if (approval.type === ApprovalType.ELICITATION) {
// Validate form - metadata is typed as ElicitationMetadata after type check
const { schema } = approval.metadata;
const required = (schema.required as string[]) || [];
const errors: Record<string, string> = {};
for (const fieldName of required) {
const value = formData[fieldName];
const isEmptyString = typeof value === 'string' && value.trim() === '';
if (value === undefined || value === null || isEmptyString) {
errors[fieldName] = 'This field is required';
}
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
onApprove(formData);
} else {
onApprove(undefined, rememberChoice);
}
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium text-sm">
{approval.type === ApprovalType.ELICITATION
? 'Information Request'
: 'Approval Required'}
</span>
</div>
{/* Content */}
{approval.type === ApprovalType.COMMAND_CONFIRMATION ? (
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Wrench className="h-4 w-4 flex-shrink-0" />
<span className="font-medium text-sm break-words min-w-0">
Tool: {approval.metadata.toolName}
</span>
</div>
<div className="min-w-0">
<span className="font-medium text-sm block mb-2">Command:</span>
<pre className="bg-muted/50 p-3 rounded-md text-xs overflow-auto max-h-40 border border-border break-words whitespace-pre-wrap max-w-full text-red-600 dark:text-red-400">
{approval.metadata.command}
</pre>
</div>
{approval.metadata.originalCommand &&
approval.metadata.originalCommand !== approval.metadata.command && (
<div className="min-w-0">
<span className="text-xs text-muted-foreground">
Original: {approval.metadata.originalCommand}
</span>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-md border border-yellow-200 dark:border-yellow-800">
<p className="text-xs text-yellow-800 dark:text-yellow-200">
This command requires approval because it may modify your system.
</p>
</div>
</div>
) : approval.type === ApprovalType.ELICITATION ? (
<div className="space-y-4 min-w-0">
<div className="bg-muted/50 p-3 rounded-md border border-border min-w-0">
<p className="text-sm font-medium mb-1 break-words">
{approval.metadata.prompt}
</p>
<p className="text-xs text-muted-foreground break-words">
From: {approval.metadata.serverName || 'Dexto Agent'}
</p>
</div>
<div>
{(() => {
const { schema } = approval.metadata;
if (!schema?.properties || typeof schema.properties !== 'object') {
return (
<p className="text-sm text-red-600 dark:text-red-400">
Invalid form schema
</p>
);
}
const required = (schema.required as string[]) || [];
const properties = schema.properties;
return (
<div className="space-y-4">
{Object.entries(properties).map(([fieldName, fieldSchema]) => {
const isRequired = required.includes(fieldName);
return renderFormField(
fieldName,
fieldSchema as JSONSchema7,
isRequired
);
})}
</div>
);
})()}
</div>
</div>
) : approval.type === ApprovalType.TOOL_CONFIRMATION ? (
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Wrench className="h-4 w-4 flex-shrink-0" />
<span className="font-medium text-sm break-words min-w-0">
Tool: {approval.metadata.toolName}
</span>
</div>
{approval.metadata.description && (
<p className="text-sm break-words">{approval.metadata.description}</p>
)}
<div className="min-w-0">
<span className="font-medium text-sm block mb-2">Arguments:</span>
<pre className="bg-muted/50 p-3 rounded-md text-xs overflow-auto max-h-40 border border-border break-words whitespace-pre-wrap max-w-full">
{JSON.stringify(approval.metadata.args, null, 2)}
</pre>
</div>
{/* Only show "Remember choice" for tool confirmations, not command confirmations */}
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id="remember"
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked === true)}
/>
<label htmlFor="remember" className="text-sm">
Remember this choice for this session
</label>
</div>
</div>
) : null}
{/* Actions */}
<div className="flex gap-2 justify-end pt-3 border-t border-border">
<Button variant="outline" onClick={onDeny} size="sm">
{approval.type === ApprovalType.ELICITATION ? 'Decline' : 'Deny'}
</Button>
<Button onClick={handleApprove} size="sm">
{approval.type === ApprovalType.ELICITATION ? 'Submit' : 'Approve'}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
import React, { useState } from 'react';
import { useMemories, useDeleteMemory, type Memory } from './hooks/useMemories';
import { formatRelativeTime } from '@/lib/date-utils';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from './ui/dialog';
import { ScrollArea } from './ui/scroll-area';
import { Brain, Plus, Trash2, Calendar, Tag, AlertTriangle, RefreshCw, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Alert, AlertDescription } from './ui/alert';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import CreateMemoryModal from './CreateMemoryModal';
interface MemoryPanelProps {
isOpen: boolean;
onClose: () => void;
variant?: 'inline' | 'modal';
}
export default function MemoryPanel({ isOpen, onClose, variant = 'modal' }: MemoryPanelProps) {
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedMemoryForDelete, setSelectedMemoryForDelete] = useState<Memory | null>(null);
const { data: memories = [], isLoading: loading, error } = useMemories(isOpen);
const deleteMemoryMutation = useDeleteMemory();
const handleDeleteMemory = async (memoryId: string) => {
await deleteMemoryMutation.mutateAsync({ memoryId });
setDeleteDialogOpen(false);
setSelectedMemoryForDelete(null);
};
const truncateContent = (content: string, maxLength: number = 120) => {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + '...';
};
// Filter memories based on search query
const filteredMemories = memories.filter((memory) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
memory.content.toLowerCase().includes(query) ||
memory.tags?.some((tag) => tag.toLowerCase().includes(query))
);
});
const content = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center space-x-2">
<Brain className="h-4 w-4" />
<h2 className="text-base font-semibold">Memories</h2>
<Badge variant="secondary" className="text-xs">
{memories.length}
</Badge>
</div>
</div>
{/* Search Bar with Create Button */}
<div className="p-3 border-b border-border/50">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search memories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setCreateModalOpen(true)}
className="h-8 w-8 p-0 shrink-0"
aria-label="Create new memory"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create Memory</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Error Display */}
{error && (
<div className="p-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error.message}</AlertDescription>
</Alert>
</div>
)}
{/* Memories List */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
</div>
) : filteredMemories.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Brain className="h-8 w-8 mx-auto mb-2 opacity-50" />
{searchQuery.trim() ? (
<>
<p>No memories found</p>
<p className="text-sm">Try a different search term</p>
</>
) : (
<>
<p>No memories yet</p>
<p className="text-sm">
Type # in chat or use the + button above
</p>
</>
)}
</div>
) : (
filteredMemories.map((memory) => (
<div
key={memory.id}
className={cn(
'group p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all',
memory.metadata?.pinned && 'ring-1 ring-primary/30 bg-primary/5'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 space-y-2">
{/* Content */}
<p className="text-sm leading-relaxed">
{truncateContent(memory.content)}
</p>
{/* Tags */}
{memory.tags && memory.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<Tag className="h-3 w-3 text-muted-foreground" />
{memory.tags.map((tag, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs px-1.5 py-0"
>
{tag}
</Badge>
))}
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatRelativeTime(memory.updatedAt)}</span>
</div>
{memory.metadata?.source && (
<Badge
variant="secondary"
className="text-xs px-1.5 py-0"
>
{memory.metadata.source}
</Badge>
)}
{memory.metadata?.pinned && (
<Badge
variant="secondary"
className="text-xs px-1.5 py-0"
>
Pinned
</Badge>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedMemoryForDelete(memory);
setDeleteDialogOpen(true);
}}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete memory"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
</div>
))
)}
</div>
</ScrollArea>
{/* Create Memory Modal */}
<CreateMemoryModal open={isCreateModalOpen} onClose={() => setCreateModalOpen(false)} />
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Memory</span>
</DialogTitle>
<DialogDescription>
This will permanently delete this memory. This action cannot be undone.
{selectedMemoryForDelete && (
<span className="block mt-2 text-sm font-medium max-h-20 overflow-y-auto">
{truncateContent(selectedMemoryForDelete.content, 100)}
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() =>
selectedMemoryForDelete &&
handleDeleteMemory(selectedMemoryForDelete.id)
}
disabled={deleteMemoryMutation.isPending}
className="flex items-center space-x-2"
>
<Trash2 className="h-4 w-4" />
<span>
{deleteMemoryMutation.isPending ? 'Deleting...' : 'Delete Memory'}
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
if (variant === 'inline') {
return <div className="h-full">{content}</div>;
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<DialogTitle>Memories</DialogTitle>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Lock, Eye, FileText, Mic, Brain } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import type { ModelInfo } from './types';
interface CapabilityIconsProps {
supportedFileTypes: ModelInfo['supportedFileTypes'];
hasApiKey: boolean;
showReasoning?: boolean;
showLockIcon?: boolean;
className?: string;
size?: 'sm' | 'md';
}
interface CapabilityBadgeProps {
icon: React.ReactNode;
label: string;
variant?: 'default' | 'warning' | 'success' | 'info';
}
function CapabilityBadge({ icon, label, variant = 'default' }: CapabilityBadgeProps) {
const variantStyles = {
default: 'bg-muted/80 text-muted-foreground hover:bg-muted hover:text-foreground',
warning: 'bg-amber-500/10 text-amber-500 hover:bg-amber-500/20',
success: 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20',
info: 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20',
};
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200 cursor-default',
variantStyles[variant]
)}
>
{icon}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
export function CapabilityIcons({
supportedFileTypes,
hasApiKey,
showReasoning,
showLockIcon = true,
className,
size = 'sm',
}: CapabilityIconsProps) {
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4';
return (
<div className={cn('flex items-center gap-1', className)}>
{supportedFileTypes?.includes('image') && (
<CapabilityBadge
icon={<Eye className={iconSize} />}
label="Vision / Image support"
variant="success"
/>
)}
{supportedFileTypes?.includes('pdf') && (
<CapabilityBadge
icon={<FileText className={iconSize} />}
label="PDF support"
variant="info"
/>
)}
{supportedFileTypes?.includes('audio') && (
<CapabilityBadge
icon={<Mic className={iconSize} />}
label="Audio support"
variant="info"
/>
)}
{showReasoning && (
<CapabilityBadge
icon={<Brain className={iconSize} />}
label="Extended thinking"
variant="default"
/>
)}
{showLockIcon && !hasApiKey && (
<CapabilityBadge
icon={<Lock className={iconSize} />}
label="Click to add API key"
variant="warning"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
import React from 'react';
import { Star, HelpCircle } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines } from './constants';
import { CapabilityIcons } from './CapabilityIcons';
import type { ModelInfo, ProviderCatalog } from './types';
interface CompactModelCardProps {
provider: LLMProvider;
model: ModelInfo;
providerInfo: ProviderCatalog;
isFavorite: boolean;
isActive: boolean;
onClick: () => void;
onToggleFavorite: () => void;
}
// Provider display name mapping
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Claude',
google: 'Gemini',
openai: 'GPT',
groq: 'Groq',
xai: 'Grok',
cohere: 'Cohere',
'openai-compatible': 'Custom',
dexto: 'Dexto',
};
// Providers that have multi-vendor models (don't strip provider prefixes from display name)
const MULTI_VENDOR_PROVIDERS = new Set([
'openrouter',
'dexto',
'openai-compatible',
'litellm',
'glama',
'bedrock',
'vertex',
]);
export function CompactModelCard({
provider,
model,
providerInfo,
isFavorite,
isActive,
onClick,
onToggleFavorite,
}: CompactModelCardProps) {
const displayName = model.displayName || model.name;
const hasApiKey = providerInfo.hasApiKey;
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
// Build description for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
`Provider: ${providerInfo.name}`,
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
!hasApiKey && 'API key required',
...priceLines,
].filter(Boolean) as string[];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={onClick}
onKeyDown={(event) => {
const target = event.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = event.key === 'Enter';
const isSpace =
event.key === ' ' ||
event.key === 'Spacebar' ||
event.code === 'Space';
if (!isEnter && !isSpace) return;
if (isSpace) event.preventDefault();
onClick();
}}
className={cn(
'relative flex items-center gap-2.5 px-3 py-2 rounded-xl border transition-all duration-150 cursor-pointer group whitespace-nowrap',
'hover:bg-accent/50 hover:border-primary/30',
isActive
? 'bg-primary/10 border-primary/40 shadow-sm'
: 'border-border/40 bg-card/60',
!hasApiKey && 'opacity-70'
)}
role="button"
tabIndex={0}
>
{/* Provider Logo */}
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
{PROVIDER_LOGOS[provider] ? (
<img
src={PROVIDER_LOGOS[provider]}
alt={`${provider} logo`}
width={20}
height={20}
className={cn(
'object-contain',
needsDarkModeInversion(provider) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* Model Name */}
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold text-foreground leading-tight truncate">
{providerName}
</span>
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{MULTI_VENDOR_PROVIDERS.has(provider)
? displayName
: displayName.replace(
new RegExp(`^${providerName}\\s*`, 'i'),
''
)}
</span>
</div>
{/* Capability Icons */}
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
size="sm"
className="flex-shrink-0"
/>
{/* Favorite Star */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className={cn(
'flex-shrink-0 p-0.5 rounded-full transition-all duration-200',
'hover:scale-110 active:scale-95',
'opacity-0 group-hover:opacity-100',
isFavorite && 'opacity-100'
)}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={cn(
'h-3.5 w-3.5 transition-all',
isFavorite
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground/50 hover:text-yellow-400'
)}
/>
</button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
import React from 'react';
import { Star, HelpCircle, Lock, X, Pencil } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines, hasLogo } from './constants';
import { CapabilityIcons } from './CapabilityIcons';
import type { ModelInfo, ProviderCatalog } from './types';
interface ModelCardProps {
provider: LLMProvider;
model: ModelInfo;
providerInfo?: ProviderCatalog;
isFavorite: boolean;
isActive: boolean;
onClick: () => void;
onToggleFavorite: () => void;
onDelete?: () => void;
onEdit?: () => void;
size?: 'sm' | 'md' | 'lg';
isCustom?: boolean;
/** Installed local model (downloaded via CLI) */
isInstalled?: boolean;
}
// Provider display name mapping
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Claude',
google: 'Gemini',
openai: 'GPT',
groq: 'Groq',
xai: 'Grok',
cohere: 'Cohere',
openrouter: 'OpenRouter',
'openai-compatible': 'Custom',
litellm: 'LiteLLM',
glama: 'Glama',
local: 'Local',
ollama: 'Ollama',
dexto: 'Dexto',
};
// Parse display name into provider and model parts
function parseModelName(
displayName: string,
provider: string
): { providerName: string; modelName: string; suffix?: string } {
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
// For multi-vendor or custom model providers, show the full display name without parsing
if (
provider === 'openrouter' ||
provider === 'dexto' ||
provider === 'openai-compatible' ||
provider === 'litellm' ||
provider === 'glama' ||
provider === 'bedrock' ||
provider === 'vertex'
) {
return { providerName, modelName: displayName };
}
// Extract suffix like (Reasoning) if present
const suffixMatch = displayName.match(/\(([^)]+)\)$/);
const suffix = suffixMatch ? suffixMatch[1] : undefined;
const nameWithoutSuffix = suffix ? displayName.replace(/\s*\([^)]+\)$/, '') : displayName;
// Try to extract model variant (remove provider prefix if present)
let modelName = nameWithoutSuffix;
const lowerName = nameWithoutSuffix.toLowerCase();
const lowerProvider = providerName.toLowerCase();
if (lowerName.startsWith(lowerProvider)) {
modelName = nameWithoutSuffix.slice(providerName.length).trim();
}
// Clean up common patterns
modelName = modelName.replace(/^[-\s]+/, '').replace(/^(claude|gemini|gpt|grok)\s*/i, '');
return { providerName, modelName: modelName || nameWithoutSuffix, suffix };
}
export function ModelCard({
provider,
model,
providerInfo,
isFavorite,
isActive,
onClick,
onToggleFavorite,
onDelete,
onEdit,
size = 'md',
isCustom = false,
isInstalled = false,
}: ModelCardProps) {
const displayName = model.displayName || model.name;
// Local/ollama/installed models don't need API keys
// Custom models are user-configured, so don't show lock (they handle their own auth)
const noApiKeyNeeded = isInstalled || isCustom || provider === 'local' || provider === 'ollama';
const hasApiKey = noApiKeyNeeded || (providerInfo?.hasApiKey ?? false);
const { providerName, modelName, suffix } = parseModelName(displayName, provider);
// Build description lines for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
`Model: ${displayName}`,
isInstalled
? 'Installed via CLI'
: provider === 'local'
? 'Local Model'
: provider === 'openai-compatible'
? 'Custom Model'
: `Provider: ${providerInfo?.name}`,
model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
!hasApiKey && 'API key required (click to add)',
...priceLines,
].filter(Boolean) as string[];
const sizeClasses = {
sm: 'px-2 py-4 h-[200px] w-full',
md: 'px-3 py-5 h-[230px] w-full',
lg: 'px-4 py-6 h-[275px] w-full',
};
const logoSizes = {
sm: { width: 36, height: 36, container: 'w-10 h-10' },
md: { width: 48, height: 48, container: 'w-14 h-14' },
lg: { width: 60, height: 60, container: 'w-16 h-16' },
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={onClick}
onKeyDown={(event) => {
const target = event.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = event.key === 'Enter';
const isSpace =
event.key === ' ' ||
event.key === 'Spacebar' ||
event.code === 'Space';
if (!isEnter && !isSpace) return;
if (isSpace) event.preventDefault();
onClick();
}}
className={cn(
'relative flex flex-col items-center rounded-2xl border-2 transition-all duration-200 cursor-pointer group overflow-hidden',
sizeClasses[size],
'hover:bg-accent/40 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5',
isActive
? 'bg-primary/10 border-primary shadow-lg shadow-primary/10'
: 'border-border/50 bg-card/60 backdrop-blur-sm',
!hasApiKey && 'opacity-70'
)}
role="button"
tabIndex={0}
>
{/* Lock Icon - Top Left (when no API key) */}
{!hasApiKey && (
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute top-2 left-2 p-1.5 rounded-full bg-amber-500/20 z-10">
<Lock className="h-3.5 w-3.5 text-amber-500" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
Click to add API key
</TooltipContent>
</Tooltip>
)}
{/* Action Buttons - Top Left for custom/installed models */}
{(isCustom || isInstalled) && (onEdit || onDelete) && (
<div className="absolute top-2 left-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-all duration-200">
{onEdit && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className={cn(
'p-1.5 rounded-full transition-all duration-200',
'hover:bg-primary/20 hover:scale-110 active:scale-95'
)}
aria-label={
isInstalled
? 'Edit installed model'
: 'Edit custom model'
}
>
<Pencil className="h-4 w-4 text-muted-foreground/60 hover:text-primary" />
</button>
)}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className={cn(
'p-1.5 rounded-full transition-all duration-200',
'hover:bg-destructive/20 hover:scale-110 active:scale-95'
)}
aria-label={
isInstalled
? 'Delete installed model'
: 'Delete custom model'
}
>
<X className="h-4 w-4 text-muted-foreground/60 hover:text-destructive" />
</button>
)}
</div>
)}
{/* Favorite Star - Top Right */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
className={cn(
'absolute top-2 right-2 p-1.5 rounded-full transition-all duration-200 z-10',
'hover:bg-yellow-500/20 hover:scale-110 active:scale-95',
'opacity-0 group-hover:opacity-100',
isFavorite && 'opacity-100'
)}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={cn(
'h-4 w-4 transition-all duration-200',
isFavorite
? 'fill-yellow-400 text-yellow-400 drop-shadow-[0_0_3px_rgba(250,204,21,0.5)]'
: 'text-muted-foreground/60 hover:text-yellow-400'
)}
/>
</button>
{/* Provider Logo */}
<div
className={cn(
'flex items-center justify-center rounded-xl bg-muted/60 mb-1.5',
logoSizes[size].container
)}
>
{hasLogo(provider) ? (
<img
src={PROVIDER_LOGOS[provider]}
alt={`${provider} logo`}
width={logoSizes[size].width}
height={logoSizes[size].height}
className={cn(
'object-contain',
needsDarkModeInversion(provider) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-6 w-6 text-muted-foreground" />
)}
</div>
{/* Model Name */}
<div className="text-center flex-1 flex flex-col min-w-0 w-full">
<div
className={cn(
'font-bold text-foreground leading-tight',
size === 'sm' ? 'text-base' : 'text-lg'
)}
>
{providerName}
</div>
<div
className={cn(
'text-muted-foreground leading-tight mt-0.5 line-clamp-3',
size === 'sm' ? 'text-sm' : 'text-base'
)}
>
{modelName}
</div>
{suffix && (
<div className="text-xs text-primary/90 font-medium mt-1">
({suffix})
</div>
)}
</div>
{/* Capability Icons - fixed height to ensure consistent card layout */}
<div className="mt-auto pt-2 h-8 flex items-center justify-center">
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
showLockIcon={false}
size={size === 'sm' ? 'sm' : 'md'}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Star, HelpCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
import type { ProviderCatalog, ModelInfo } from './types';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import type { LLMProvider } from '@dexto/core';
import {
PROVIDER_LOGOS,
needsDarkModeInversion,
PROVIDER_PRICING_URLS,
formatPricingLines,
} from './constants';
import { CapabilityIcons } from './CapabilityIcons';
type Props = {
providerId: LLMProvider;
provider: ProviderCatalog;
models: ModelInfo[];
favorites: string[];
currentModel?: { provider: string; model: string; displayName?: string };
onToggleFavorite: (providerId: LLMProvider, modelName: string) => void;
onUse: (providerId: LLMProvider, model: ModelInfo) => void;
defaultExpanded?: boolean;
};
export function ProviderSection({
providerId,
provider,
models,
favorites,
currentModel,
onToggleFavorite,
onUse,
defaultExpanded = true,
}: Props) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
if (models.length === 0) return null;
const isCurrentModel = (modelName: string) =>
currentModel?.provider === providerId && currentModel?.model === modelName;
const isFavorite = (modelName: string) => favorites.includes(`${providerId}|${modelName}`);
const hasActiveModel = models.some((m) => isCurrentModel(m.name));
return (
<TooltipProvider>
<div className="space-y-2">
{/* Provider Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200',
'hover:bg-accent/50 group',
hasActiveModel && 'bg-primary/5'
)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-muted/50">
{PROVIDER_LOGOS[providerId] ? (
<img
src={PROVIDER_LOGOS[providerId]}
alt={`${providerId} logo`}
width={20}
height={20}
className={cn(
'object-contain',
needsDarkModeInversion(providerId) &&
'dark:invert dark:brightness-0 dark:contrast-200'
)}
/>
) : (
<HelpCircle className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="text-left">
<span className="text-sm font-semibold">{provider.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{models.length} model{models.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{!provider.hasApiKey && (
<span className="text-xs px-2 py-1 rounded-md bg-amber-500/10 text-amber-500">
API Key Required
</span>
)}
{PROVIDER_PRICING_URLS[providerId] && (
<a
href={PROVIDER_PRICING_URLS[providerId]}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
Pricing
<ExternalLink className="h-3 w-3" />
</a>
)}
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{/* Models List */}
{isExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pl-2">
{models.map((model) => {
const displayName = model.displayName || model.name;
const isActive = isCurrentModel(model.name);
const favorite = isFavorite(model.name);
const hasApiKey = provider.hasApiKey;
// Build description lines for tooltip
const priceLines = formatPricingLines(model.pricing || undefined);
const descriptionLines = [
model.maxInputTokens &&
`Max tokens: ${model.maxInputTokens.toLocaleString()}`,
Array.isArray(model.supportedFileTypes) &&
model.supportedFileTypes.length > 0 &&
`Supports: ${model.supportedFileTypes.join(', ')}`,
model.default && 'Default model',
!hasApiKey && 'API key required',
...priceLines,
].filter(Boolean) as string[];
return (
<Tooltip key={model.name}>
<TooltipTrigger asChild>
<div
onClick={() => onUse(providerId, model)}
onKeyDown={(e) => {
const target = e.target as HTMLElement | null;
if (target && target.closest('button')) return;
const isEnter = e.key === 'Enter';
const isSpace =
e.key === ' ' ||
e.key === 'Spacebar' ||
e.code === 'Space';
if (isSpace) e.preventDefault();
if (isEnter || isSpace) {
onUse(providerId, model);
}
}}
className={cn(
'group/card relative flex items-center gap-3 px-4 py-3 rounded-xl border transition-all duration-150 cursor-pointer outline-none',
'hover:bg-accent/50 hover:border-accent-foreground/20 hover:shadow-sm',
'focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50',
isActive
? 'bg-primary/5 border-primary/30 shadow-sm ring-1 ring-primary/20'
: 'border-border/40 bg-card/30',
!hasApiKey && 'opacity-60'
)}
role="button"
tabIndex={0}
>
{/* Model Name */}
<div className="flex-1 text-left min-w-0">
<div className="text-sm font-medium truncate">
{displayName}
</div>
</div>
{/* Capability Icons */}
<CapabilityIcons
supportedFileTypes={model.supportedFileTypes}
hasApiKey={hasApiKey}
/>
{/* Favorite Star */}
<button
onKeyDown={(e) => {
const isSpace =
e.key === ' ' ||
e.key === 'Spacebar' ||
e.code === 'Space';
const isEnter = e.key === 'Enter';
if (isEnter || isSpace) {
e.stopPropagation();
if (isSpace) e.preventDefault();
}
}}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(providerId, model.name);
}}
className={cn(
'flex-shrink-0 p-1.5 rounded-lg transition-all duration-200',
'hover:bg-accent hover:scale-110 active:scale-95',
'opacity-0 group-hover/card:opacity-100',
favorite && 'opacity-100'
)}
aria-label={
favorite
? 'Remove from favorites'
: 'Add to favorites'
}
>
<Star
className={cn(
'h-4 w-4 transition-colors',
favorite
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:text-yellow-500'
)}
/>
</button>
{/* Active Indicator */}
{isActive && (
<div className="absolute inset-y-2 left-0 w-1 bg-primary rounded-full" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-0.5">
{descriptionLines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '../../lib/utils';
type Props = {
value: string;
onChange: (v: string) => void;
placeholder?: string;
autoFocus?: boolean;
};
export function SearchBar({
value,
onChange,
placeholder = 'Search models...',
autoFocus = true,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [autoFocus]);
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
'w-full h-11 pl-10 pr-10 rounded-xl',
'bg-muted/50 border border-border/50',
'text-sm placeholder:text-muted-foreground/70',
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
'transition-all duration-200'
)}
/>
{value && (
<button
type="button"
onClick={() => onChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-accent transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Sparkles, FlaskConical, Zap } from 'lucide-react';
import type { LLMProvider } from '@dexto/core';
// Provider logo file mapping - single source of truth
// Empty string means "use Bot icon fallback" in components
export const PROVIDER_LOGOS: Record<LLMProvider, string> = {
openai: '/logos/openai.svg',
anthropic: '/logos/claude-color.svg',
google: '/logos/gemini-color.svg',
groq: '/logos/groq.svg',
xai: '/logos/grok.svg',
'openai-compatible': '/logos/openai.svg',
cohere: '/logos/cohere-color.svg',
openrouter: '/logos/openrouter.svg',
litellm: '/logos/litellm.svg',
glama: '/logos/glama.svg',
vertex: '/logos/gemini-color.svg', // Vertex AI uses Gemini logo (primary model family)
bedrock: '/logos/aws-color.svg',
local: '', // Uses Bot icon fallback - local GGUF models via node-llama-cpp
ollama: '/logos/ollama.svg', // Ollama server
dexto: '/logos/dexto/dexto_logo_icon.svg', // Dexto gateway - use Dexto logo
};
// Provider pricing URLs (for quick access from Model Picker)
export const PROVIDER_PRICING_URLS: Partial<Record<LLMProvider, string>> = {
openai: 'https://platform.openai.com/docs/pricing',
anthropic: 'https://www.anthropic.com/pricing#api',
google: 'https://ai.google.dev/gemini-api/docs/pricing',
groq: 'https://groq.com/pricing/',
xai: 'https://docs.x.ai/docs/models',
cohere: 'https://cohere.com/pricing',
openrouter: 'https://openrouter.ai/models',
litellm: 'https://docs.litellm.ai/',
glama: 'https://glama.ai/',
vertex: 'https://cloud.google.com/vertex-ai/generative-ai/pricing',
bedrock: 'https://aws.amazon.com/bedrock/pricing/',
// TODO: make this a valid URL
dexto: 'https://dexto.ai/pricing',
// 'openai-compatible' intentionally omitted (varies by vendor)
};
// Helper: Format pricing from permillion to perthousand tokens
export function formatPricingLines(pricing?: {
inputPerM?: number;
outputPerM?: number;
cacheReadPerM?: number;
cacheWritePerM?: number;
currency?: 'USD';
unit?: 'per_million_tokens';
}): string[] {
if (!pricing) return [];
// Bail early if required pricing fields are missing
if (pricing.inputPerM == null || pricing.outputPerM == null) return [];
const currency = pricing.currency || 'USD';
const cur = currency === 'USD' ? '$' : '';
const lines: string[] = [];
lines.push(
`Cost: ${cur}${pricing.inputPerM.toFixed(2)} in / ${cur}${pricing.outputPerM.toFixed(2)} out per 1M tokens`
);
if (pricing.cacheReadPerM != null) {
lines.push(`Cache read: ${cur}${pricing.cacheReadPerM.toFixed(2)} per 1M tokens`);
}
if (pricing.cacheWritePerM != null) {
lines.push(`Cache write: ${cur}${pricing.cacheWritePerM.toFixed(2)} per 1M tokens`);
}
return lines;
}
// Logos that have hardcoded colors and don't need dark mode inversion
export const COLORED_LOGOS: readonly LLMProvider[] = [
'google',
'cohere',
'anthropic',
'vertex',
'dexto',
] as const;
// Helper to check if a logo needs dark mode inversion
export const needsDarkModeInversion = (provider: LLMProvider): boolean => {
return !COLORED_LOGOS.includes(provider);
};
// Helper to check if a provider has a logo
export const hasLogo = (provider: LLMProvider): boolean => {
return !!PROVIDER_LOGOS[provider];
};
// Model capability icons - sleek emojis for current capabilities
export const CAPABILITY_ICONS = {
// File type capabilities (what we currently use)
image: <span className="text-sm">🖼</span>,
audio: <span className="text-sm">🎵</span>,
pdf: <span className="text-sm">📄</span>,
// Other capabilities we currently have
reasoning: <span className="text-sm">🧠</span>,
experimental: (
<FlaskConical className="h-3.5 w-3.5 text-muted-foreground hover:text-amber-500 transition-colors cursor-help" />
),
new: (
<Sparkles className="h-3.5 w-3.5 text-muted-foreground hover:text-yellow-500 transition-colors cursor-help" />
),
realtime: (
<Zap className="h-3.5 w-3.5 text-muted-foreground hover:text-blue-500 transition-colors cursor-help" />
),
};

View File

@@ -0,0 +1 @@
export { default } from './ModelPickerModal';

View File

@@ -0,0 +1,80 @@
import type { SupportedFileType, LLMProvider } from '@dexto/core';
export type ModelInfo = {
name: string;
displayName?: string;
default?: boolean;
maxInputTokens: number;
supportedFileTypes: SupportedFileType[];
pricing?: {
inputPerM: number;
outputPerM: number;
cacheReadPerM?: number;
cacheWritePerM?: number;
currency?: 'USD';
unit?: 'per_million_tokens';
};
};
export type ProviderCatalog = {
name: string;
hasApiKey: boolean;
primaryEnvVar: string;
supportsBaseURL: boolean;
models: ModelInfo[];
};
export type CatalogResponse = { providers: Record<LLMProvider, ProviderCatalog> };
export type CurrentLLMConfigResponse = {
config: {
provider: string;
model: string;
displayName?: string;
baseURL?: string;
apiKey?: string;
maxInputTokens?: number;
};
};
export function favKey(provider: string, model: string) {
return `${provider}|${model}`;
}
export function validateBaseURL(url: string): { isValid: boolean; error?: string } {
const str = url.trim();
if (!str.length) return { isValid: true };
try {
const u = new URL(str);
if (!['http:', 'https:'].includes(u.protocol)) {
return { isValid: false, error: 'URL must use http:// or https://' };
}
if (!u.pathname.includes('/v1')) {
return { isValid: false, error: 'URL must include /v1 for compatibility' };
}
return { isValid: true };
} catch {
return { isValid: false, error: 'Invalid URL format' };
}
}
export const FAVORITES_STORAGE_KEY = 'dexto:modelFavorites';
export const CUSTOM_MODELS_STORAGE_KEY = 'dexto:customModels';
// Default favorites for new users (newest/best models)
export const DEFAULT_FAVORITES = [
'anthropic|claude-sonnet-4-5-20250929',
'anthropic|claude-opus-4-5-20251101',
'openai|gpt-5.1-chat-latest',
'openai|gpt-5.1',
'google|gemini-3-pro-preview',
'google|gemini-3-pro-image-preview',
];
// Minimal storage for custom models - other fields are inferred
export interface CustomModelStorage {
name: string; // Model identifier
baseURL: string; // OpenAI-compatible endpoint
maxInputTokens?: number; // Optional, defaults to 128k
maxOutputTokens?: number; // Optional, provider decides
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
type Props = {
onClick: () => void;
className?: string;
variant?: 'outline' | 'ghost';
side?: 'top' | 'right' | 'bottom' | 'left';
};
export function NewChatButton({ onClick, className, variant = 'outline', side = 'bottom' }: Props) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={variant}
size="sm"
onClick={onClick}
className={['h-8 w-8 p-0', className].filter(Boolean).join(' ')}
aria-label="New chat"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side={side}>New Chat (K)</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default NewChatButton;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Clock, CheckCircle, XCircle, History } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
export interface ExecutionHistoryItem {
id: string;
toolName: string;
timestamp: Date;
success: boolean;
duration?: number;
}
interface ExecutionHistoryProps {
history: ExecutionHistoryItem[];
}
export function ExecutionHistory({ history }: ExecutionHistoryProps) {
if (history.length === 0) {
return null;
}
const successCount = history.filter((h) => h.success).length;
const failureCount = history.filter((h) => !h.success).length;
return (
<div className="border-t border-border pt-4 mt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">Execution History</h3>
<Badge variant="secondary" className="text-xs">
{history.length}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<CheckCircle className="h-3 w-3 text-green-500" />
<span>{successCount}</span>
</div>
<div className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-red-500" />
<span>{failureCount}</span>
</div>
</div>
</div>
<ScrollArea className="h-32">
<div className="space-y-2">
{history.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{item.success ? (
<CheckCircle className="h-3 w-3 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
{item.toolName}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{item.duration}ms
</span>
)}
<span>{new Date(item.timestamp).toLocaleTimeString()}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,570 @@
import React, { useState, useCallback, useRef } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, AlertTriangle, CheckCircle, PanelLeftClose, PanelLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import ConnectServerModal from '../ConnectServerModal';
import { ServersList } from './ServersList';
import { ToolsList } from './ToolsList';
import { ToolInputForm } from './ToolInputForm';
import { ToolResult } from './ToolResult';
import { ExecutionHistory, type ExecutionHistoryItem } from './ExecutionHistory';
import type { ToolResult as ToolResultType } from '@dexto/core';
import { cn } from '@/lib/utils';
import { client } from '@/lib/client';
import { useServers, useServerTools } from '../hooks/useServers';
import type { McpServer, McpTool } from '../hooks/useServers';
export default function PlaygroundView() {
const [selectedServer, setSelectedServer] = useState<McpServer | null>(null);
const [selectedTool, setSelectedTool] = useState<McpTool | null>(null);
const [toolInputs, setToolInputs] = useState<Record<string, any>>({});
const [toolResult, setToolResult] = useState<ToolResultType | null>(null);
const [currentError, setCurrentError] = useState<string | null>(null);
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
const [isConnectModalOpen, setIsConnectModalOpen] = useState(false);
const [executionLoading, setExecutionLoading] = useState(false);
const [executionHistory, setExecutionHistory] = useState<ExecutionHistoryItem[]>([]);
const [clipboardNotification, setClipboardNotification] = useState<{
message: string;
type: 'success' | 'error';
} | null>(null);
// Search states
const [serverSearchQuery, setServerSearchQuery] = useState('');
const [toolSearchQuery, setToolSearchQuery] = useState('');
// Responsive sidebar states
const [showServersSidebar, setShowServersSidebar] = useState(true);
const [showToolsSidebar, setShowToolsSidebar] = useState(true);
const executionAbortControllerRef = useRef<AbortController | null>(null);
const {
data: servers = [],
isLoading: serversLoading,
error: serversError,
refetch: refetchServers,
} = useServers();
const {
data: tools = [],
isLoading: toolsLoading,
error: toolsError,
} = useServerTools(
selectedServer?.id || null,
!!selectedServer && selectedServer.status === 'connected'
);
const handleError = (message: string, area?: 'servers' | 'tools' | 'execution' | 'input') => {
console.error(`Playground Error (${area || 'general'}):`, message);
if (area !== 'input') {
setCurrentError(message);
}
};
const handleServerSelect = useCallback((server: McpServer) => {
setSelectedServer(server);
setSelectedTool(null);
setToolResult(null);
setCurrentError(null);
setInputErrors({});
}, []);
const handleToolSelect = useCallback((tool: McpTool) => {
setSelectedTool(tool);
setToolResult(null);
setCurrentError(null);
setInputErrors({});
const defaultInputs: Record<string, any> = {};
if (tool.inputSchema && tool.inputSchema.properties) {
for (const key in tool.inputSchema.properties) {
const prop = tool.inputSchema.properties[key];
if (prop.default !== undefined) {
defaultInputs[key] = prop.default;
} else {
if (prop.type === 'boolean') defaultInputs[key] = false;
else if (prop.type === 'number' || prop.type === 'integer')
defaultInputs[key] = '';
else if (prop.type === 'object' || prop.type === 'array')
defaultInputs[key] = '';
else defaultInputs[key] = '';
}
}
}
setToolInputs(defaultInputs);
}, []);
const handleInputChange = useCallback(
(
inputName: string,
value: any,
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
) => {
setToolInputs((prev) => ({ ...prev, [inputName]: value }));
if (inputErrors[inputName]) {
setInputErrors((prev) => ({ ...prev, [inputName]: '' }));
}
if (type === 'object' || type === 'array') {
if (value === '') return;
try {
JSON.parse(value);
} catch {
setInputErrors((prev) => ({ ...prev, [inputName]: 'Invalid JSON format' }));
return;
}
}
},
[inputErrors]
);
const validateInputs = useCallback((): boolean => {
if (!selectedTool || !selectedTool.inputSchema || !selectedTool.inputSchema.properties) {
return true;
}
const currentInputErrors: Record<string, string> = {};
let allValid = true;
for (const key in selectedTool.inputSchema.properties) {
const prop = selectedTool.inputSchema.properties[key];
const value = toolInputs[key];
if (selectedTool.inputSchema.required?.includes(key)) {
if (
value === undefined ||
value === '' ||
(prop.type === 'boolean' && typeof value !== 'boolean')
) {
currentInputErrors[key] = 'This field is required.';
allValid = false;
continue;
}
}
if (
(prop.type === 'number' || prop.type === 'integer') &&
value !== '' &&
isNaN(Number(value))
) {
currentInputErrors[key] = 'Must be a valid number.';
allValid = false;
}
if ((prop.type === 'object' || prop.type === 'array') && value !== '') {
try {
JSON.parse(value as string);
} catch {
currentInputErrors[key] = 'Invalid JSON format.';
allValid = false;
}
}
}
setInputErrors(currentInputErrors);
return allValid;
}, [selectedTool, toolInputs]);
const handleExecuteTool = useCallback(async () => {
if (!selectedServer || !selectedTool) {
handleError('No server or tool selected for execution.', 'execution');
return;
}
executionAbortControllerRef.current?.abort();
const controller = new AbortController();
executionAbortControllerRef.current = controller;
setCurrentError(null);
setToolResult(null);
if (!validateInputs()) {
handleError('Please correct the input errors.', 'input');
return;
}
const executionStart = Date.now();
const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionLoading(true);
try {
const processedInputs: Record<string, any> = {};
if (selectedTool.inputSchema && selectedTool.inputSchema.properties) {
for (const key in selectedTool.inputSchema.properties) {
const prop = selectedTool.inputSchema.properties[key];
let value = toolInputs[key];
if (prop.type === 'number') {
value = value === '' ? undefined : Number(value);
} else if (prop.type === 'integer') {
if (value === '') {
value = undefined;
} else {
const num = Number(value);
if (!Number.isInteger(num)) {
setInputErrors((prev) => ({
...prev,
[key]: 'Must be a valid integer.',
}));
setExecutionLoading(false);
return;
}
value = num;
}
} else if (prop.type === 'boolean') {
if (typeof value === 'string') {
value = value === 'true';
} else {
value = Boolean(value);
}
} else if (
(prop.type === 'object' || prop.type === 'array') &&
typeof value === 'string' &&
value.trim() !== ''
) {
try {
value = JSON.parse(value);
} catch {
setInputErrors((prev) => ({
...prev,
[key]: 'Invalid JSON before sending.',
}));
setExecutionLoading(false);
return;
}
} else if (
(prop.type === 'object' || prop.type === 'array') &&
(value === undefined || value === '')
) {
value = undefined;
}
if (value !== undefined) {
processedInputs[key] = value;
}
}
}
const response = await client.api.mcp.servers[':serverId'].tools[
':toolName'
].execute.$post(
{
param: {
serverId: selectedServer.id,
toolName: selectedTool.id,
},
json: processedInputs,
},
{ init: { signal: controller.signal } }
);
if (!response.ok) {
throw new Error('Tool execution failed');
}
const resultData = await response.json();
const duration = Date.now() - executionStart;
setToolResult(resultData);
setExecutionHistory((prev) => [
{
id: executionId,
toolName: selectedTool.name,
timestamp: new Date(),
success: true,
duration,
},
...prev.slice(0, 9),
]);
} catch (err: any) {
if (err.name !== 'AbortError') {
const duration = Date.now() - executionStart;
handleError(err.message, 'execution');
if (
err.message &&
(!toolResult || toolResult.success || toolResult.error !== err.message)
) {
setToolResult({ success: false, error: err.message });
}
setExecutionHistory((prev) => [
{
id: executionId,
toolName: selectedTool?.name || 'Unknown',
timestamp: new Date(),
success: false,
duration,
},
...prev.slice(0, 9),
]);
}
} finally {
if (!controller.signal.aborted) {
setExecutionLoading(false);
}
}
}, [selectedServer, selectedTool, toolInputs, validateInputs, toolResult]);
const handleModalClose = () => {
setIsConnectModalOpen(false);
refetchServers();
};
const copyToClipboard = async (text: string, successMessage?: string) => {
try {
await navigator.clipboard.writeText(text);
setClipboardNotification({
message: successMessage || 'Copied to clipboard',
type: 'success',
});
setTimeout(() => setClipboardNotification(null), 3000);
} catch (err) {
setClipboardNotification({
message: 'Failed to copy to clipboard. Please check browser permissions.',
type: 'error',
});
setTimeout(() => setClipboardNotification(null), 5000);
console.error('Failed to copy to clipboard:', err);
}
};
const copyToolConfiguration = () => {
if (!selectedTool || !selectedServer) return;
const config = {
server: selectedServer.name,
tool: selectedTool.name,
inputs: toolInputs,
timestamp: new Date().toISOString(),
};
copyToClipboard(JSON.stringify(config, null, 2), 'Tool configuration copied!');
};
const copyToolResult = () => {
if (!toolResult) return;
const resultText =
typeof toolResult.data === 'object'
? JSON.stringify(toolResult.data, null, 2)
: String(toolResult.data);
copyToClipboard(resultText, 'Tool result copied!');
};
const shareToolConfig = () => {
if (!selectedTool || !selectedServer) return;
const shareText = `Check out this Dexto tool configuration:\n\nServer: ${selectedServer.name}\nTool: ${selectedTool.name}\nInputs: ${JSON.stringify(toolInputs, null, 2)}`;
if (navigator.share) {
navigator.share({
title: `Dexto Tool: ${selectedTool.name}`,
text: shareText,
});
} else {
copyToClipboard(shareText, 'Tool configuration copied for sharing!');
}
};
return (
<div className="flex h-screen bg-background text-foreground antialiased">
{/* Servers Sidebar */}
<aside
className={cn(
'w-72 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
'lg:relative lg:translate-x-0',
showServersSidebar
? 'translate-x-0'
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
)}
>
{showServersSidebar && (
<>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-border">
<Link to="/">
<Button variant="outline" size="sm" className="gap-1.5">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setShowServersSidebar(false)}
className="lg:hidden"
>
<PanelLeftClose className="h-4 w-4" />
</Button>
</div>
<ServersList
servers={servers}
selectedServer={selectedServer}
isLoading={serversLoading}
error={serversError?.message || currentError}
searchQuery={serverSearchQuery}
onSearchChange={setServerSearchQuery}
onServerSelect={handleServerSelect}
onConnectNew={() => setIsConnectModalOpen(true)}
/>
</>
)}
</aside>
{/* Tools Sidebar */}
<aside
className={cn(
'w-80 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
'lg:relative lg:translate-x-0',
showToolsSidebar
? 'translate-x-0'
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
)}
>
{showToolsSidebar && (
<ToolsList
tools={tools}
selectedTool={selectedTool}
selectedServer={selectedServer}
isLoading={toolsLoading}
error={
toolsError?.message ||
(selectedServer?.status === 'connected' ? currentError : null)
}
searchQuery={toolSearchQuery}
onSearchChange={setToolSearchQuery}
onToolSelect={handleToolSelect}
/>
)}
</aside>
{/* Main Content */}
<main className="flex-1 p-6 flex flex-col bg-muted/30 overflow-y-auto">
{/* Header */}
<div className="pb-3 mb-4 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{!showServersSidebar && (
<Button
variant="outline"
size="sm"
onClick={() => setShowServersSidebar(true)}
className="lg:hidden"
>
<PanelLeft className="h-4 w-4" />
</Button>
)}
{!showToolsSidebar && (
<Button
variant="outline"
size="sm"
onClick={() => setShowToolsSidebar(true)}
className="lg:hidden"
>
<PanelLeft className="h-4 w-4" />
</Button>
)}
<h2 className="text-lg font-semibold text-foreground">Tool Runner</h2>
</div>
</div>
</div>
{/* Clipboard Notification */}
{clipboardNotification && (
<Alert
variant={clipboardNotification.type === 'error' ? 'destructive' : 'default'}
className={cn(
'mb-4',
clipboardNotification.type === 'success' &&
'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-400'
)}
>
{clipboardNotification.type === 'error' && (
<AlertTriangle className="h-4 w-4" />
)}
{clipboardNotification.type === 'success' && (
<CheckCircle className="h-4 w-4" />
)}
<AlertDescription>{clipboardNotification.message}</AlertDescription>
</Alert>
)}
{/* Error Display */}
{currentError && selectedTool && (!toolResult || !toolResult.success) && (
<div className="mb-4 p-3 border border-destructive/50 bg-destructive/10 rounded-md text-destructive text-sm">
<p className="font-medium">Error:</p>
<p>{currentError}</p>
</div>
)}
{/* Empty State */}
{!selectedTool && (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md">
<div className="mb-4">
<ArrowLeft className="h-12 w-12 mx-auto text-muted-foreground opacity-50" />
</div>
<h3 className="text-lg font-semibold mb-2">Select a Tool</h3>
<p className="text-muted-foreground text-sm">
Choose a tool from the left panel to start testing and experimenting
with MCP capabilities.
</p>
</div>
</div>
)}
{/* Tool Content */}
{selectedTool && (
<div className="space-y-6">
{/* Tool Info Card */}
<div className="p-4 border border-border rounded-lg bg-card shadow-sm">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold text-primary mb-1">
{selectedTool.name}
</h3>
{selectedTool.description && (
<p className="text-sm text-muted-foreground">
{selectedTool.description}
</p>
)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>Server: {selectedServer?.name}</p>
{executionHistory.filter(
(h) => h.toolName === selectedTool.name
).length > 0 && (
<p>
Runs:{' '}
{
executionHistory.filter(
(h) => h.toolName === selectedTool.name
).length
}
</p>
)}
</div>
</div>
</div>
{/* Tool Input Form */}
<ToolInputForm
tool={selectedTool}
inputs={toolInputs}
errors={inputErrors}
isLoading={executionLoading}
onInputChange={handleInputChange}
onSubmit={handleExecuteTool}
onCopyConfig={copyToolConfiguration}
onShareConfig={shareToolConfig}
/>
{/* Tool Result */}
{toolResult && (
<ToolResult
result={toolResult}
toolName={selectedTool.name}
onCopyResult={copyToolResult}
/>
)}
{/* Execution History */}
<ExecutionHistory history={executionHistory} />
</div>
)}
</main>
<ConnectServerModal isOpen={isConnectModalOpen} onClose={handleModalClose} />
</div>
);
}

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { Server, Check, AlertCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/components/hooks/useServers';
interface ServersListProps {
servers: McpServer[];
selectedServer: McpServer | null;
isLoading: boolean;
error: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onServerSelect: (server: McpServer) => void;
onConnectNew: () => void;
}
export function ServersList({
servers,
selectedServer,
isLoading,
error,
searchQuery,
onSearchChange,
onServerSelect,
onConnectNew,
}: ServersListProps) {
const filteredServers = servers.filter((server) =>
server.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusIcon = (status: McpServer['status']) => {
switch (status) {
case 'connected':
return <Check className="h-3 w-3" />;
case 'error':
return <AlertCircle className="h-3 w-3" />;
case 'disconnected':
default:
return <Loader2 className="h-3 w-3 animate-spin" />;
}
};
const getStatusColor = (status: McpServer['status']) => {
switch (status) {
case 'connected':
return 'bg-green-100 text-green-700 dark:bg-green-700/20 dark:text-green-400';
case 'error':
return 'bg-red-100 text-red-700 dark:bg-red-700/20 dark:text-red-400';
case 'disconnected':
return 'bg-slate-100 text-slate-600 dark:bg-slate-700/20 dark:text-slate-400';
default:
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/20 dark:text-yellow-400';
}
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pb-3 mb-3 border-b border-border">
<div className="flex items-center gap-2 mb-3">
<Server className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">MCP Servers</h2>
{isLoading && servers.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
)}
</div>
<Input
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 text-sm"
/>
</div>
{/* Error State */}
{error && servers.length === 0 && !isLoading && (
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
<p className="font-medium">Error loading servers</p>
<p className="text-xs mt-1">{error}</p>
</div>
)}
{/* Loading State */}
{isLoading && servers.length === 0 && (
<div className="flex-1 space-y-2 pr-1">
{[1, 2, 3].map((i) => (
<div key={i} className="p-2.5 rounded-lg border border-border">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{servers.length === 0 && !isLoading && !error && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Server className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No servers available</p>
<p className="text-xs text-muted-foreground mt-1">
Connect a server to get started
</p>
</div>
</div>
)}
{/* Servers List */}
{filteredServers.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
{filteredServers.map((server) => (
<button
key={server.id}
onClick={() => server.status === 'connected' && onServerSelect(server)}
disabled={server.status !== 'connected'}
className={cn(
'w-full p-2.5 rounded-lg text-left transition-all duration-200',
'hover:shadow-sm border border-transparent',
selectedServer?.id === server.id
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
: 'hover:bg-muted hover:border-border',
server.status !== 'connected' && 'opacity-50 cursor-not-allowed'
)}
title={
server.status !== 'connected'
? `${server.name} is ${server.status}`
: server.name
}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm truncate">{server.name}</span>
<Badge
variant="secondary"
className={cn(
'text-xs px-1.5 py-0 h-5 flex items-center gap-1',
getStatusColor(server.status)
)}
>
{getStatusIcon(server.status)}
{server.status}
</Badge>
</div>
</button>
))}
</div>
)}
{/* No Results */}
{filteredServers.length === 0 && servers.length > 0 && (
<div className="flex-1 flex items-center justify-center p-4">
<p className="text-sm text-muted-foreground">No servers match your search</p>
</div>
)}
{/* Connect Button */}
<Button
onClick={onConnectNew}
variant="outline"
className="mt-auto w-full sticky bottom-0 bg-background"
size="sm"
>
<Server className="h-4 w-4 mr-2" />
Connect New Server
</Button>
</div>
);
}

View File

@@ -0,0 +1,341 @@
import React, { ChangeEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Copy, Share2, Zap } from 'lucide-react';
import type { McpTool } from '@/components/hooks/useServers';
// Infer the property schema type from the tool's input schema
type JsonSchemaProperty = NonNullable<NonNullable<McpTool['inputSchema']>['properties']>[string];
interface ToolInputFormProps {
tool: McpTool;
inputs: Record<string, any>;
errors: Record<string, string>;
isLoading: boolean;
onInputChange: (
name: string,
value: any,
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
) => void;
onSubmit: () => void;
onCopyConfig?: () => void;
onShareConfig?: () => void;
}
interface ToolTemplate {
name: string;
description: string;
apply: (tool: McpTool) => Record<string, any>;
}
const toolTemplates: ToolTemplate[] = [
{
name: 'Quick Test',
description: 'Fill with test values',
apply: (tool: McpTool) => {
const defaults: Record<string, any> = {};
if (tool.inputSchema?.properties) {
Object.entries(tool.inputSchema.properties).forEach(
([key, prop]: [string, any]) => {
if (prop.type === 'string') defaults[key] = `test-${key}`;
else if (prop.type === 'number') defaults[key] = 42;
else if (prop.type === 'boolean') defaults[key] = true;
else if (prop.type === 'object') defaults[key] = '{"example": "value"}';
else if (prop.type === 'array') defaults[key] = '["example"]';
}
);
}
return defaults;
},
},
{
name: 'Required Only',
description: 'Fill only required fields',
apply: (tool: McpTool) => {
const defaults: Record<string, any> = {};
if (tool.inputSchema?.properties && tool.inputSchema?.required) {
tool.inputSchema.required.forEach((key: string) => {
const prop = tool.inputSchema!.properties![key];
if (prop.type === 'string') defaults[key] = '';
else if (prop.type === 'number') defaults[key] = '';
else if (prop.type === 'boolean') defaults[key] = false;
else if (prop.type === 'object') defaults[key] = '{}';
else if (prop.type === 'array') defaults[key] = '[]';
});
}
return defaults;
},
},
{
name: 'Clear All',
description: 'Clear all fields',
apply: () => ({}),
},
];
export function ToolInputForm({
tool,
inputs,
errors,
isLoading,
onInputChange,
onSubmit,
onCopyConfig,
onShareConfig,
}: ToolInputFormProps) {
const hasInputs =
tool.inputSchema?.properties && Object.keys(tool.inputSchema.properties).length > 0;
const renderInput = (key: string, prop: JsonSchemaProperty) => {
const isRequired = tool.inputSchema?.required?.includes(key);
const errorMsg = errors[key];
const baseInputClassName = `w-full ${errorMsg ? 'border-destructive focus-visible:ring-destructive' : ''}`;
// Enum select
if (prop.enum && Array.isArray(prop.enum)) {
const isEnumBoolean = prop.enum.every(
(v: string | number | boolean) => typeof v === 'boolean'
);
const isEnumNumeric = prop.enum.every(
(v: string | number | boolean) => typeof v === 'number'
);
return (
<Select
value={
inputs[key] === undefined && prop.default !== undefined
? String(prop.default)
: String(inputs[key] || '')
}
onValueChange={(value) => {
let parsedValue: string | number | boolean = value;
if (isEnumBoolean) parsedValue = value === 'true';
else if (isEnumNumeric) parsedValue = Number(value);
onInputChange(key, parsedValue, prop.type);
}}
disabled={isLoading}
>
<SelectTrigger id={key} className={baseInputClassName}>
<SelectValue
placeholder={`Select ${prop.description || key}${isRequired ? '' : ' (optional)'}...`}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((enumValue: string | number | boolean) => (
<SelectItem key={String(enumValue)} value={String(enumValue)}>
{String(enumValue)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// Boolean checkbox
if (prop.type === 'boolean') {
return (
<Checkbox
id={key}
checked={
inputs[key] === undefined && prop.default !== undefined
? Boolean(prop.default)
: Boolean(inputs[key])
}
onCheckedChange={(checked) => onInputChange(key, checked, prop.type)}
disabled={isLoading}
className={errorMsg ? 'border-destructive ring-destructive' : ''}
/>
);
}
// Object/Array textarea
if (prop.type === 'object' || prop.type === 'array') {
return (
<Textarea
id={key}
value={
inputs[key] === undefined && prop.default !== undefined
? JSON.stringify(prop.default, null, 2)
: inputs[key] || ''
}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
onInputChange(key, e.target.value, prop.type)
}
rows={5}
className={`${baseInputClassName} font-mono text-sm min-h-[100px] resize-y`}
placeholder={`Enter JSON for ${prop.description || key}`}
disabled={isLoading}
/>
);
}
// String/Number input
let inputFieldType: React.HTMLInputTypeAttribute = 'text';
if (prop.type === 'number' || prop.type === 'integer') inputFieldType = 'number';
if (prop.format === 'date-time') inputFieldType = 'datetime-local';
if (prop.format === 'date') inputFieldType = 'date';
if (prop.format === 'email') inputFieldType = 'email';
if (prop.format === 'uri') inputFieldType = 'url';
if (prop.format === 'password') inputFieldType = 'password';
return (
<Input
type={inputFieldType}
id={key}
value={
inputs[key] === undefined && prop.default !== undefined
? String(prop.default)
: String(inputs[key] || '')
}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onInputChange(key, e.target.value, prop.type)
}
className={baseInputClassName}
placeholder={prop.description || `Enter ${key}`}
disabled={isLoading}
step={prop.type === 'number' || prop.type === 'integer' ? 'any' : undefined}
/>
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-5 p-4 border border-border rounded-lg bg-card shadow-sm"
>
{/* Quick Fill Templates */}
{hasInputs && (
<div className="border-b border-border pb-4">
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Quick Fill</h4>
<div className="flex flex-wrap gap-2">
{toolTemplates.map((template, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
onClick={() => {
const newInputs = template.apply(tool);
Object.entries(newInputs).forEach(([key, value]) => {
const prop = tool.inputSchema?.properties?.[key];
onInputChange(key, value, prop?.type);
});
}}
className="text-xs"
title={template.description}
>
{template.name}
</Button>
))}
</div>
</div>
)}
{/* Form Inputs */}
{!hasInputs && (
<p className="text-sm text-muted-foreground py-2">
This tool does not require any inputs.
</p>
)}
{hasInputs &&
Object.entries(tool.inputSchema!.properties!).map(([key, prop]) => {
const isRequired = tool.inputSchema?.required?.includes(key);
const errorMsg = errors[key];
return (
<div key={key} className="grid gap-1.5">
<div
className={`flex ${
prop.type === 'boolean'
? 'flex-row items-center space-x-3'
: 'flex-col'
}`}
>
<Label
htmlFor={key}
className={`${
prop.type === 'boolean'
? 'leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
: 'capitalize font-medium'
}`}
>
{prop.description ||
key
.replace(/([A-Z]+(?=[A-Z][a-z]))|([A-Z][a-z])/g, ' $&')
.trim()
.replace(/_/g, ' ')}
{isRequired && (
<span className="text-destructive text-lg ml-0.5">*</span>
)}
</Label>
{prop.type === 'boolean' ? (
renderInput(key, prop)
) : (
<div className="w-full">{renderInput(key, prop)}</div>
)}
</div>
{errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
</div>
);
})}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 pt-2">
<Button
type="submit"
disabled={isLoading || Object.keys(errors).some((k) => errors[k] !== '')}
className="flex-1"
>
{isLoading ? (
'Executing...'
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Run Tool
</>
)}
</Button>
{hasInputs && Object.keys(inputs).length > 0 && (
<>
{onCopyConfig && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onCopyConfig}
>
<Copy className="h-3 w-3 mr-2" />
Copy
</Button>
)}
{onShareConfig && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onShareConfig}
>
<Share2 className="h-3 w-3 mr-2" />
Share
</Button>
)}
</>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { CheckCircle, XCircle, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ToolResult as ToolResultType } from '@dexto/core';
interface ToolResultProps {
result: ToolResultType;
toolName: string;
onCopyResult?: () => void;
}
export function ToolResult({ result, toolName, onCopyResult }: ToolResultProps) {
const renderResultContent = () => {
// Check if this is an image result by examining the data structure
const isImageResult =
result.data &&
typeof result.data === 'object' &&
(Array.isArray(result.data) ||
(typeof result.data === 'object' && Array.isArray((result.data as any).content)));
if (isImageResult && result.data) {
let imgSrc = '';
let imagePart: { data?: string; mimeType?: string; type?: string } | null = null;
let nonImageParts: any[] = [];
if (Array.isArray(result.data)) {
imagePart = result.data.find((part) => part && part.type === 'image');
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
}
} else if (
result.data &&
typeof result.data === 'object' &&
Array.isArray((result.data as any).content)
) {
const partsArray = (result.data as any).content as any[];
imagePart = partsArray.find((part) => part && part.type === 'image');
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
}
nonImageParts = partsArray.filter((part) => part && part.type !== 'image');
} else if (typeof result.data === 'string') {
if (result.data.startsWith('data:image')) {
imgSrc = result.data;
} else if (
result.data.startsWith('http://') ||
result.data.startsWith('https://')
) {
imgSrc = result.data;
}
}
if (imgSrc) {
return (
<img
src={imgSrc}
alt="Tool result"
className="my-2 max-h-96 w-auto rounded-lg border border-border shadow-sm"
/>
);
} else if (nonImageParts.length > 0) {
return (
<div className="space-y-3">
{nonImageParts.map((part, idx) => (
<pre
key={idx}
className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border font-mono overflow-x-auto max-h-64"
>
{typeof part === 'object'
? JSON.stringify(part, null, 2)
: String(part)}
</pre>
))}
</div>
);
}
}
// Default result rendering
return (
<pre className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border overflow-x-auto">
{typeof result.data === 'object'
? JSON.stringify(result.data, null, 2)
: String(result.data)}
</pre>
);
};
return (
<div className="mt-6 p-4 border border-border rounded-lg bg-card shadow-sm">
<div className="flex justify-between items-center mb-3 pb-3 border-b border-border">
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<h3 className="text-base font-semibold text-foreground">
{result.success ? 'Success' : 'Error'}
</h3>
<span className="text-sm text-muted-foreground"> {toolName}</span>
</div>
{onCopyResult && result.success && (
<Button variant="outline" size="sm" onClick={onCopyResult}>
<Copy className="h-3 w-3 mr-2" />
Copy Result
</Button>
)}
</div>
{result.success ? (
<div className="space-y-3">{renderResultContent()}</div>
) : (
<div className="p-3 bg-destructive/10 rounded-md">
<p className="text-sm text-destructive font-semibold">Error executing tool:</p>
<pre className="mt-1 text-xs text-destructive whitespace-pre-wrap break-all">
{result.error || 'Unknown error'}
</pre>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { Wrench, Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { McpServer, McpTool } from '@/components/hooks/useServers';
interface ToolsListProps {
tools: McpTool[];
selectedTool: McpTool | null;
selectedServer: McpServer | null;
isLoading: boolean;
error: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onToolSelect: (tool: McpTool) => void;
}
export function ToolsList({
tools,
selectedTool,
selectedServer,
isLoading,
error,
searchQuery,
onSearchChange,
onToolSelect,
}: ToolsListProps) {
const filteredTools = tools.filter(
(tool) =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pb-3 mb-3 border-b border-border">
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Tools</h2>
{isLoading && tools.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
)}
{tools.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs">
{filteredTools.length}
</Badge>
)}
</div>
{tools.length > 0 && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Search tools..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 text-sm pl-7"
/>
</div>
)}
</div>
{/* No Server Selected */}
{!selectedServer && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">Select a server</p>
<p className="text-xs text-muted-foreground mt-1">
Choose a connected server to view its tools
</p>
</div>
</div>
)}
{/* Server Not Connected */}
{selectedServer && selectedServer.status !== 'connected' && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">Server not connected</p>
<p className="text-xs text-muted-foreground mt-1">
"{selectedServer.name}" is {selectedServer.status}
</p>
</div>
</div>
)}
{/* Error State */}
{error && selectedServer?.status === 'connected' && !isLoading && (
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
<p className="font-medium">Error loading tools</p>
<p className="text-xs mt-1">{error}</p>
</div>
)}
{/* Loading State */}
{isLoading && selectedServer?.status === 'connected' && tools.length === 0 && (
<div className="flex-1 space-y-2 pr-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-3 rounded-lg border border-border">
<div className="flex items-start gap-2">
<Skeleton className="h-4 w-4 mt-0.5 flex-shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{selectedServer &&
selectedServer.status === 'connected' &&
!isLoading &&
tools.length === 0 &&
!error && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No tools available</p>
<p className="text-xs text-muted-foreground mt-1">
No tools found for {selectedServer.name}
</p>
</div>
</div>
)}
{/* Tools List */}
{filteredTools.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
{filteredTools.map((tool) => (
<button
key={tool.id}
onClick={() => onToolSelect(tool)}
className={cn(
'w-full p-3 rounded-lg text-left transition-all duration-200',
'hover:shadow-sm border border-transparent',
selectedTool?.id === tool.id
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
: 'hover:bg-muted hover:border-border'
)}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
{tool.description && (
<p
className={cn(
'text-xs mt-1 line-clamp-2',
selectedTool?.id === tool.id
? 'text-primary-foreground/80'
: 'text-muted-foreground'
)}
>
{tool.description}
</p>
)}
</div>
</div>
</button>
))}
</div>
)}
{/* No Search Results */}
{filteredTools.length === 0 && tools.length > 0 && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Search className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No tools match your search</p>
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { X, ArrowUp } from 'lucide-react';
import { Button } from './ui/button';
import type { QueuedMessage } from './hooks/useQueue';
import { isTextPart } from '../types';
interface QueuedMessagesDisplayProps {
messages: QueuedMessage[];
onEditMessage: (message: QueuedMessage) => void;
onRemoveMessage: (messageId: string) => void;
}
/**
* Displays queued messages with visual indicators and keyboard hints.
*/
export function QueuedMessagesDisplay({
messages,
onEditMessage,
onRemoveMessage,
}: QueuedMessagesDisplayProps) {
if (messages.length === 0) return null;
// Extract text content from message
const getMessageText = (message: QueuedMessage): string => {
const textParts = message.content.filter(isTextPart).map((part) => part.text);
return textParts.join(' ') || '[attachment]';
};
// Truncate text to max lines
const truncateText = (text: string, maxLines: number = 2): string => {
const lines = text.split('\n');
if (lines.length <= maxLines) {
return text.length > 100 ? text.slice(0, 100) + '...' : text;
}
return lines.slice(0, maxLines).join('\n') + '...';
};
return (
<div className="px-4 pb-2">
<div className="rounded-lg border border-border/50 bg-muted/30 p-2">
{/* Header */}
<div className="flex items-center justify-between mb-1.5 px-1">
<span className="text-xs font-medium text-muted-foreground">
{messages.length} message{messages.length !== 1 ? 's' : ''} queued
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<kbd className="px-1 py-0.5 bg-muted border border-border rounded text-[10px]">
<ArrowUp className="h-2.5 w-2.5 inline" />
</kbd>
<span className="ml-1">to edit</span>
</div>
</div>
{/* Messages list */}
<div className="space-y-1">
{messages.map((message, index) => (
<div
key={message.id}
className="group flex items-start gap-2 px-1 py-1 rounded hover:bg-muted/50 transition-colors"
>
{/* Arrow indicator */}
<span className="text-muted-foreground text-xs mt-0.5 select-none">
{index === messages.length - 1 ? '↳' : '│'}
</span>
{/* Message content */}
<button
onClick={() => onEditMessage(message)}
className="flex-1 text-left text-sm text-muted-foreground italic hover:text-foreground transition-colors truncate"
title="Click to edit"
>
{truncateText(getMessageText(message))}
</button>
{/* Remove button */}
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveMessage(message.id)}
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove from queue"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { Image as ImageIcon, Loader2 } from 'lucide-react';
import type { ResourceMetadata } from '@dexto/core';
import { useResourceContent } from './hooks/useResourceContent';
import type { NormalizedResourceItem, ResourceState } from './hooks/useResourceContent';
import { filterAndSortResources } from '../lib/utils.js';
interface ResourceAutocompleteProps {
resources: ResourceMetadata[];
query: string;
selectedIndex: number;
onSelect: (resource: ResourceMetadata) => void;
onHoverIndex?: (index: number) => void;
loading?: boolean;
}
export default function ResourceAutocomplete({
resources,
query,
selectedIndex,
onSelect,
onHoverIndex,
loading,
}: ResourceAutocompleteProps) {
const filtered = React.useMemo(
() => filterAndSortResources(resources, query),
[resources, query]
);
const imageResourceUris = React.useMemo(
() => filtered.filter((r) => (r.mimeType || '').startsWith('image/')).map((r) => r.uri),
[filtered]
);
const imageResources = useResourceContent(imageResourceUris);
const itemRefs = React.useRef<HTMLLIElement[]>([]);
React.useEffect(() => {
const el = itemRefs.current[selectedIndex];
if (el && el.scrollIntoView) {
el.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex, filtered.length]);
if (!query && filtered.length === 0 && !loading) {
return (
<div className="px-3 py-2 text-sm text-muted-foreground">
<div>No resources available.</div>
<div className="text-xs mt-1 text-muted-foreground/80">
Connect an MCP server or enable internal resources to attach references.
</div>
</div>
);
}
// Generate stable IDs for ARIA
const getOptionId = (uri: string) =>
`resource-option-${btoa(uri).replace(/[^a-zA-Z0-9]/g, '')}`;
const activeDescendant = filtered[selectedIndex]
? getOptionId(filtered[selectedIndex].uri)
: undefined;
return loading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">Loading resources</div>
) : filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
<div>No resources match "{query}"</div>
<div className="text-xs mt-1 text-muted-foreground/80">
Tip: @ references only work at start or after spaces
</div>
</div>
) : (
<ul
role="listbox"
aria-label="Resource suggestions"
aria-activedescendant={activeDescendant}
className="py-1 text-sm max-h-64 overflow-y-auto"
>
<li className="px-3 py-1.5 text-xs text-muted-foreground/80 border-b border-border">
@ references files/resources Works at start or after spaces
</li>
{filtered.map((r, idx) => {
const optionId = getOptionId(r.uri);
return (
<li
key={r.uri}
id={optionId}
role="option"
aria-selected={idx === selectedIndex}
ref={(node) => {
if (node) itemRefs.current[idx] = node;
}}
className={
'px-3 py-2 cursor-pointer flex items-center gap-3 ' +
(idx === selectedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground')
}
onMouseEnter={() => onHoverIndex?.(idx)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(r);
}}
>
{(r.mimeType || '').startsWith('image/') && (
<ResourceThumbnail resourceState={imageResources[r.uri]} />
)}
<div className="min-w-0 flex-1 mr-2">
<div className="truncate font-medium">
{r.name || r.uri.split('/').pop() || r.uri}
</div>
<div className="truncate text-xs text-muted-foreground">{r.uri}</div>
</div>
{r.serverName && (
<span className="ml-auto shrink-0 rounded bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
{r.serverName}
</span>
)}
</li>
);
})}
</ul>
);
}
interface ResourceThumbnailProps {
resourceState?: ResourceState;
}
function ResourceThumbnail({ resourceState }: ResourceThumbnailProps) {
const baseClasses =
'w-10 h-10 rounded-md border border-border bg-muted/40 flex items-center justify-center overflow-hidden flex-shrink-0';
if (!resourceState) {
return (
<div className={baseClasses}>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
if (resourceState.status === 'loading') {
return (
<div className={baseClasses}>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (resourceState.status === 'error') {
return (
<div className={baseClasses} title={resourceState.error}>
<ImageIcon className="h-4 w-4 text-destructive" />
</div>
);
}
const imageItem = resourceState.data?.items.find(
(item): item is Extract<NormalizedResourceItem, { kind: 'image' }> => item.kind === 'image'
);
if (!imageItem || !('src' in imageItem)) {
return (
<div className={baseClasses}>
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
);
}
return (
<div className={baseClasses}>
<img
src={imageItem.src}
alt={imageItem.alt || 'Resource preview'}
className="w-full h-full object-cover"
/>
</div>
);
}

View File

@@ -0,0 +1,389 @@
import React, { useState } from 'react';
import { useDebounce } from 'use-debounce';
import {
useSearchMessages,
useSearchSessions,
type SearchResult,
type SessionSearchResult,
} from './hooks/useSearch';
import { formatDate, formatTime } from '@/lib/date-utils';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { Dialog, DialogContent } from './ui/dialog';
import { ScrollArea } from './ui/scroll-area';
import {
Search,
MessageSquare,
Clock,
User,
Bot,
Settings,
X,
ChevronRight,
AlertTriangle,
RefreshCw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Alert, AlertDescription } from './ui/alert';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
interface SearchPanelProps {
isOpen: boolean;
onClose: () => void;
onNavigateToSession: (sessionId: string, messageIndex?: number) => void;
variant?: 'inline' | 'modal' | 'popover';
}
type SearchMode = 'messages' | 'sessions';
export default function SearchPanel({
isOpen,
onClose,
onNavigateToSession,
variant = 'modal',
}: SearchPanelProps) {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery] = useDebounce(searchQuery, 300);
const [searchMode, setSearchMode] = useState<SearchMode>('messages');
const [roleFilter, setRoleFilter] = useState<string>('all');
const [sessionFilter, setSessionFilter] = useState<string>('');
// Use TanStack Query hooks for search
const {
data: messageData,
isLoading: messageLoading,
error: messageError,
} = useSearchMessages(
debouncedQuery,
sessionFilter || undefined,
20,
isOpen && searchMode === 'messages'
);
const {
data: sessionData,
isLoading: sessionLoading,
error: sessionError,
} = useSearchSessions(debouncedQuery, isOpen && searchMode === 'sessions');
// Derive state from query results
const messageResults = messageData?.results || [];
const sessionResults = sessionData?.results || [];
const isLoading = searchMode === 'messages' ? messageLoading : sessionLoading;
const error = searchMode === 'messages' ? messageError : sessionError;
const total = searchMode === 'messages' ? messageData?.total || 0 : sessionData?.total || 0;
const handleResultClick = (result: SearchResult) => {
onNavigateToSession(result.sessionId, result.messageIndex);
onClose();
};
const handleSessionResultClick = (sessionResult: SessionSearchResult) => {
onNavigateToSession(sessionResult.sessionId, sessionResult.firstMatch.messageIndex);
onClose();
};
const getRoleIcon = (role: string) => {
switch (role) {
case 'user':
return <User className="w-4 h-4" />;
case 'assistant':
return <Bot className="w-4 h-4" />;
case 'system':
return <Settings className="w-4 h-4" />;
default:
return <MessageSquare className="w-4 h-4" />;
}
};
const getRoleColor = (role: string) => {
switch (role) {
case 'user':
return 'bg-blue-100 text-blue-800';
case 'assistant':
return 'bg-green-100 text-green-800';
case 'system':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const highlightText = (text: string, query: string) => {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-yellow-200 font-medium">
{part}
</mark>
) : (
part
)
);
};
const content = (
<div className={cn('flex flex-col h-full', variant === 'modal' && 'min-h-[600px]')}>
{/* Search Input - moved to top for better UX */}
<div className="p-4 border-b border-border/50 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Search Mode Toggle */}
<div className="flex gap-2">
<Button
variant={searchMode === 'messages' ? 'default' : 'outline'}
size="sm"
onClick={() => setSearchMode('messages')}
className="flex-1"
>
<MessageSquare className="w-4 h-4 mr-2" />
Messages
</Button>
<Button
variant={searchMode === 'sessions' ? 'default' : 'outline'}
size="sm"
onClick={() => setSearchMode('sessions')}
className="flex-1"
>
<Clock className="w-4 h-4 mr-2" />
Sessions
</Button>
</div>
{/* Filters for message search */}
{searchMode === 'messages' && (
<div className="flex gap-2">
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-28">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="assistant">Assistant</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Session ID (optional)"
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
className="flex-1 text-sm"
/>
</div>
)}
</div>
{/* Results */}
<div className="flex-1 overflow-hidden">
{error && (
<div className="p-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error?.message || 'Search failed'}</AlertDescription>
</Alert>
</div>
)}
<ScrollArea className="h-full">
<div className="p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin mr-2" />
<div className="text-muted-foreground">Searching...</div>
</div>
) : (
<>
{/* Results Summary */}
{searchQuery && (
<div className="mb-4 text-sm text-muted-foreground">
{total > 0 ? (
<>
Found {total}{' '}
{searchMode === 'messages'
? 'messages'
: 'sessions'}{' '}
matching "{searchQuery}"
</>
) : (
<>
No{' '}
{searchMode === 'messages'
? 'messages'
: 'sessions'}{' '}
found matching "{searchQuery}"
</>
)}
</div>
)}
{/* Message Results */}
{searchMode === 'messages' && messageResults.length > 0 && (
<div className="space-y-2">
{messageResults.map((result, index) => (
<div
key={index}
className="p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all cursor-pointer"
onClick={() => handleResultClick(result)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Badge
className={cn(
'text-xs',
getRoleColor(result.message.role)
)}
>
{getRoleIcon(result.message.role)}
<span className="ml-1 capitalize">
{result.message.role}
</span>
</Badge>
<span className="text-sm text-muted-foreground">
Session: {result.sessionId.slice(0, 8)}
...
</span>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
<div className="text-sm">
{highlightText(result.context, searchQuery)}
</div>
</div>
))}
</div>
)}
{/* Session Results */}
{searchMode === 'sessions' && sessionResults.length > 0 && (
<div className="space-y-2">
{sessionResults.map((sessionResult, index) => (
<div
key={index}
className="p-3 rounded-lg border border-border/50 bg-card hover:bg-muted/50 transition-all cursor-pointer"
onClick={() =>
handleSessionResultClick(sessionResult)
}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">
{sessionResult.sessionId.slice(0, 12)}
...
</span>
<Badge
variant="secondary"
className="text-xs"
>
{sessionResult.matchCount} matches
</Badge>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
<div className="text-sm text-muted-foreground mb-2">
{sessionResult.metadata.messageCount} messages
Created{' '}
{formatDate(sessionResult.metadata.createdAt)}
Last active{' '}
{formatTime(
sessionResult.metadata.lastActivity
)}
</div>
<div className="text-sm">
{highlightText(
sessionResult.firstMatch.context,
searchQuery
)}
</div>
</div>
))}
</div>
)}
{/* No Results */}
{searchQuery &&
!isLoading &&
(searchMode === 'messages'
? messageResults.length === 0
: sessionResults.length === 0) && (
<div className="text-center py-8 text-muted-foreground">
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>
No{' '}
{searchMode === 'messages'
? 'messages'
: 'sessions'}{' '}
found matching your search.
</p>
<p className="text-sm mt-2">
Try adjusting your search terms or filters.
</p>
</div>
)}
{/* Empty State */}
{!searchQuery && (
<div className="text-center py-8 text-muted-foreground">
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Start typing to search through your conversations.</p>
</div>
)}
</>
)}
</div>
</ScrollArea>
</div>
</div>
);
if (variant === 'inline') {
return <div className="h-full flex flex-col">{content}</div>;
}
if (variant === 'popover') {
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Popover panel */}
<div className="fixed left-4 top-4 z-50 w-[400px] max-h-[80vh] bg-popover/95 backdrop-blur-md border border-border/50 rounded-xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-border/50">
<h3 className="text-sm font-medium">Search Chats</h3>
<Button variant="ghost" size="sm" onClick={onClose} className="h-7 w-7 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[calc(80vh-48px)] overflow-y-auto">{content}</div>
</div>
</>
);
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl h-[80vh] p-0">{content}</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,796 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { serverRegistry } from '@/lib/serverRegistry';
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import {
Search,
CheckCircle,
ExternalLink,
Star,
Server,
Grid3X3,
List,
ChevronDown,
ChevronUp,
Users,
Plus,
PlusCircle,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ServerRegistryModalProps {
isOpen: boolean;
onClose: () => void;
onInstallServer: (entry: ServerRegistryEntry) => Promise<'connected' | 'requires-input'>;
onOpenConnectModal?: () => void;
refreshTrigger?: number;
disableClose?: boolean;
}
export default function ServerRegistryModal({
isOpen,
onClose,
onInstallServer,
onOpenConnectModal,
refreshTrigger,
disableClose = false,
}: ServerRegistryModalProps) {
const [entries, setEntries] = useState<ServerRegistryEntry[]>([]);
const [filteredEntries, setFilteredEntries] = useState<ServerRegistryEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
// Filter state
const [filter] = useState<ServerRegistryFilter>({});
const [searchInput, setSearchInput] = useState('');
// View state
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
const [expandedEntry, setExpandedEntry] = useState<string | null>(null);
// Ref for debouncing
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track if component is mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup effect to handle unmounting
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Abort any ongoing requests
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Load entries when modal opens
useEffect(() => {
if (!isOpen) return;
const loadEntries = async () => {
// Cancel any ongoing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
if (!isMountedRef.current) return;
setIsLoading(true);
setError(null);
try {
// Sync registry with current server status first
await serverRegistry.syncWithServerStatus();
const registryEntries = await serverRegistry.getEntries();
// Check if component is still mounted and request wasn't aborted
if (isMountedRef.current && !abortController.signal.aborted) {
setEntries(registryEntries);
setFilteredEntries(registryEntries);
}
} catch (err: unknown) {
// Only set error if component is still mounted and request wasn't aborted
if (isMountedRef.current && !abortController.signal.aborted) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to load server registry';
setError(errorMessage);
}
} finally {
// Only update loading state if component is still mounted and request wasn't aborted
if (isMountedRef.current && !abortController.signal.aborted) {
setIsLoading(false);
}
}
};
loadEntries();
}, [isOpen, refreshTrigger]);
// Debounced filter function
const debouncedApplyFilters = useCallback(
async (currentFilter: ServerRegistryFilter, currentSearchInput: string) => {
if (!isMountedRef.current) return;
try {
const filtered = await serverRegistry.getEntries({
...currentFilter,
search: currentSearchInput || undefined,
});
if (isMountedRef.current) setFilteredEntries(filtered);
} catch (err: unknown) {
if (isMountedRef.current) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to filter entries';
setError(errorMessage);
}
}
},
[]
);
// Apply filters with debouncing
useEffect(() => {
// Clear the previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set a new timer to debounce the filter operation
debounceTimerRef.current = setTimeout(() => {
debouncedApplyFilters(filter, searchInput);
}, 300); // 300ms delay
// Cleanup function to clear timer on unmount
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [filter, searchInput, entries, debouncedApplyFilters]);
// TODO: consolidate registry connection flows so modal + panels share a single state machine.
const handleInstall = async (entry: ServerRegistryEntry) => {
if (!isMountedRef.current) return;
setInstalling(entry.id);
try {
const result = await onInstallServer(entry);
if (result === 'connected') {
await serverRegistry.setInstalled(entry.id, true);
if (isMountedRef.current) {
setEntries((prev) =>
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: true } : e))
);
setFilteredEntries((prev) =>
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: true } : e))
);
}
} else if (isMountedRef.current) {
setEntries((prev) =>
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: false } : e))
);
setFilteredEntries((prev) =>
prev.map((e) => (e.id === entry.id ? { ...e, isInstalled: false } : e))
);
}
} catch (err: unknown) {
if (isMountedRef.current) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to install server';
setError(errorMessage);
}
} finally {
if (isMountedRef.current) {
setInstalling(null);
}
}
};
// Theme spec: Subtle, professional badge colors with soft backgrounds
const getCategoryColor = (category: string) => {
const colors = {
productivity: 'bg-blue-50 text-blue-700 border-blue-200/60',
development: 'bg-emerald-50 text-emerald-700 border-emerald-200/60',
research: 'bg-purple-50 text-purple-700 border-purple-200/60',
creative: 'bg-pink-50 text-pink-700 border-pink-200/60',
data: 'bg-amber-50 text-amber-700 border-amber-200/60',
communication: 'bg-indigo-50 text-indigo-700 border-indigo-200/60',
custom: 'bg-slate-50 text-slate-700 border-slate-200/60',
};
return colors[category as keyof typeof colors] || colors.custom;
};
const isCloseBlocked = disableClose || Boolean(installing);
const handleDialogOpenChange = useCallback(
(open: boolean) => {
if (!open && !isCloseBlocked) {
onClose();
}
},
[isCloseBlocked, onClose]
);
const preventCloseInteraction = isCloseBlocked
? (event: Event) => {
event.preventDefault();
}
: undefined;
return (
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="!max-w-none w-[90vw] max-h-[85vh] overflow-hidden flex flex-col !sm:max-w-none p-0 bg-gradient-to-b from-background via-background to-muted/20"
hideCloseButton
onEscapeKeyDown={isCloseBlocked ? (event) => event.preventDefault() : undefined}
onInteractOutside={preventCloseInteraction}
>
{/* Theme: Compact header with refined typography and subtle background */}
<DialogHeader className="pb-4 border-b px-6 pt-5 bg-gradient-to-r from-muted/30 via-transparent to-transparent">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20 flex-shrink-0">
<Server className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<DialogTitle className="text-lg font-semibold leading-tight mb-1">
MCP Server Registry
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Discover and add integrations to your AI assistant
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => {
if (!isCloseBlocked) {
onClose();
}
onOpenConnectModal?.();
}}
size="sm"
variant="outline"
className="h-8 px-3 text-sm font-medium hover:bg-muted whitespace-nowrap"
>
<PlusCircle className="mr-1.5 h-3.5 w-3.5" />
Connect Custom
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onClose()}
disabled={isCloseBlocked}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</div>
</DialogHeader>
{/* Theme: Clean search with subtle focus state and refined controls */}
<div className="flex gap-3 mb-5 px-6 pt-5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 pointer-events-none z-10" />
<Input
placeholder="Search servers and integrations..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-10 h-10 border focus:border-primary/40 bg-background text-sm placeholder:text-muted-foreground/50 focus:ring-1 focus:ring-primary/20 transition-all"
/>
</div>
<div className="flex bg-muted/50 rounded-md p-1 border">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setViewMode('grid');
setExpandedEntry(null);
}}
className={cn(
'h-8 px-3 rounded-sm transition-all text-sm font-medium',
viewMode === 'grid'
? 'bg-background shadow-sm'
: 'hover:bg-background/50'
)}
>
<Grid3X3 className="h-3.5 w-3.5 mr-1.5" />
Grid
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => {
setViewMode('list');
setExpandedEntry(null);
}}
className={cn(
'h-8 px-3 rounded-sm transition-all text-sm font-medium',
viewMode === 'list'
? 'bg-background shadow-sm'
: 'hover:bg-background/50'
)}
>
<List className="h-3.5 w-3.5 mr-1.5" />
List
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{error && (
<Alert
variant="destructive"
className="mb-6 border-2 border-red-300 bg-red-50 shadow-md"
>
<AlertDescription className="text-red-800 font-medium">
{error}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="flex flex-col items-center space-y-5">
<div className="h-12 w-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
<div className="text-lg text-foreground font-semibold">
Discovering servers...
</div>
<div className="text-sm text-muted-foreground">
Loading registry entries
</div>
</div>
</div>
) : filteredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="p-6 rounded-2xl bg-gradient-to-br from-muted/50 to-muted/30 border-2 border-border/30 mb-6 shadow-sm">
<Server className="h-14 w-14 text-muted-foreground/60 mx-auto" />
</div>
<div className="text-xl font-bold text-foreground mb-3">
{entries.length === 0
? 'No servers available in the registry'
: 'No servers match your search'}
</div>
<div className="text-base text-muted-foreground/80 mb-6 max-w-md">
{entries.length === 0
? 'The registry is currently empty or failed to load'
: 'Try adjusting your search terms or browse all categories'}
</div>
{searchInput && (
<Button
variant="outline"
onClick={() => setSearchInput('')}
className="border-2 border-primary/30 hover:bg-primary/10 hover:border-primary/50 font-semibold"
>
<Search className="h-4 w-4 mr-2" />
Clear search
</Button>
)}
</div>
) : (
<div
className={cn(
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
: 'space-y-3'
)}
>
{filteredEntries.map((entry, index) => {
const isExpanded = expandedEntry === entry.id;
const hasLongDescription =
entry.description && entry.description.length > 100;
return viewMode === 'grid' ? (
<Card
key={entry.id}
className={cn(
'group relative overflow-hidden transition-all duration-200 hover:-translate-y-0.5 border bg-card shadow-sm hover:shadow-lg hover:border-primary/30 flex flex-col',
isExpanded &&
'ring-1 ring-primary/20 border-primary/40 shadow-lg -translate-y-0.5'
)}
style={{ animationDelay: `${Math.min(index * 30, 300)}ms` }}
>
<div
className="cursor-pointer flex flex-col flex-1"
onClick={() =>
setExpandedEntry(isExpanded ? null : entry.id)
}
>
{/* Theme: Balanced header with medium emphasis icon and refined typography */}
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-start gap-3 mb-3">
<div className="relative flex-shrink-0">
<div className="text-2xl w-12 h-12 flex items-center justify-center rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 border border-primary/20 group-hover:border-primary/30 transition-all duration-200">
{entry.icon || '⚡'}
</div>
{entry.isInstalled && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-card flex items-center justify-center">
<CheckCircle className="h-2.5 w-2.5 text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-semibold mb-2 group-hover:text-primary transition-colors leading-snug line-clamp-2">
{entry.name}
</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
{entry.isOfficial && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 bg-blue-50 text-blue-700 border-blue-200/60 font-medium"
>
<Star className="h-2.5 w-2.5 mr-1 fill-blue-400 text-blue-400" />
Official
</Badge>
)}
{entry.category && (
<Badge
variant="outline"
className={cn(
'text-xs px-1.5 py-0 font-medium capitalize',
getCategoryColor(
entry.category
)
)}
>
{entry.category}
</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 flex flex-col flex-1">
<div className="flex-1">
{/* Theme: Readable description with proper line height */}
<p
className={cn(
'text-sm text-muted-foreground leading-relaxed transition-all duration-200',
isExpanded ? '' : 'line-clamp-3'
)}
>
{entry.description}
</p>
{isExpanded && (
<div className="space-y-3 pt-3 mt-3 border-t border-border/30 animate-in slide-in-from-top-1 duration-200">
{entry.author && (
<div className="flex items-center gap-2 text-xs">
<div className="p-1 rounded bg-muted/40">
<Users className="h-3 w-3 text-muted-foreground" />
</div>
<div>
<span className="text-muted-foreground/70">
by{' '}
</span>
<span className="font-medium text-foreground">
{entry.author}
</span>
</div>
</div>
)}
{entry.tags &&
entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{entry.tags
.slice(0, 6)
.map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs px-1.5 py-0 text-muted-foreground border-border/40 bg-muted/20 font-normal"
>
{tag}
</Badge>
))}
{entry.tags.length > 6 && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 text-muted-foreground border-border/40 bg-muted/20"
>
+
{entry.tags.length -
6}
</Badge>
)}
</div>
)}
{entry.homepage && (
<a
href={entry.homepage}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
onClick={(e) =>
e.stopPropagation()
}
>
<ExternalLink className="h-3 w-3" />
Documentation
</a>
)}
</div>
)}
</div>
{/* Theme: Clean footer with primary CTA emphasis */}
<div className="flex items-center justify-between pt-3 border-t border-border/30 flex-shrink-0 mt-auto">
{hasLongDescription && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground p-0 h-auto font-medium -ml-1"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
More
</>
)}
</Button>
)}
<div className="ml-auto">
<Button
onClick={(e) => {
e.stopPropagation();
handleInstall(entry);
}}
disabled={
entry.isInstalled ||
installing === entry.id
}
size="sm"
variant={
entry.isInstalled
? 'outline'
: 'default'
}
className={cn(
'h-8 px-4 transition-all font-semibold text-sm shadow-sm',
installing === entry.id &&
'opacity-70',
entry.isInstalled &&
'border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100',
!entry.isInstalled &&
'bg-primary hover:bg-primary/90'
)}
>
{installing === entry.id ? (
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
<span>Adding</span>
</div>
) : entry.isInstalled ? (
<div className="flex items-center gap-1.5">
<CheckCircle className="h-3.5 w-3.5" />
<span>Added</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<Plus className="h-3.5 w-3.5" />
<span>Add</span>
</div>
)}
</Button>
</div>
</div>
</CardContent>
</div>
</Card>
) : (
<Card
key={entry.id}
className={cn(
'group transition-all duration-200 hover:-translate-y-0.5 border bg-card shadow-sm hover:shadow-md hover:border-primary/30 cursor-pointer !py-0 !gap-0',
isExpanded &&
'border-primary/40 shadow-md -translate-y-0.5'
)}
onClick={() =>
setExpandedEntry(isExpanded ? null : entry.id)
}
>
<div className="p-4">
<div className="flex items-start gap-4">
<div className="relative flex-shrink-0">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-primary/15 to-primary/5 border border-primary/20 group-hover:border-primary/30 flex items-center justify-center text-2xl transition-all duration-200">
{entry.icon || '⚡'}
</div>
{entry.isInstalled && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full border-2 border-card flex items-center justify-center">
<CheckCircle className="h-2.5 w-2.5 text-white" />
</div>
)}
</div>
<div className="flex-1 min-w-0 space-y-2.5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors leading-snug">
{entry.name}
</h3>
{hasLongDescription && (
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-muted-foreground/60 transition-all duration-200 flex-shrink-0',
isExpanded &&
'rotate-180 text-muted-foreground'
)}
/>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{entry.isOfficial && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 bg-blue-50 text-blue-700 border-blue-200/60 font-medium"
>
<Star className="h-2.5 w-2.5 mr-1 fill-blue-400 text-blue-400" />
Official
</Badge>
)}
{entry.category && (
<Badge
variant="outline"
className={cn(
'text-xs px-1.5 py-0 font-medium capitalize',
getCategoryColor(
entry.category
)
)}
>
{entry.category}
</Badge>
)}
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleInstall(entry);
}}
disabled={
entry.isInstalled ||
installing === entry.id
}
size="sm"
variant={
entry.isInstalled
? 'outline'
: 'default'
}
className={cn(
'h-8 px-4 text-sm font-semibold transition-all flex-shrink-0 shadow-sm',
installing === entry.id &&
'opacity-70',
entry.isInstalled &&
'border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100',
!entry.isInstalled &&
'bg-primary hover:bg-primary/90'
)}
>
{installing === entry.id ? (
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
<span>Adding</span>
</div>
) : entry.isInstalled ? (
<div className="flex items-center gap-1.5">
<CheckCircle className="h-3.5 w-3.5" />
<span>Added</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<Plus className="h-3.5 w-3.5" />
<span>Add</span>
</div>
)}
</Button>
</div>
<div>
<p
className={cn(
'text-sm text-muted-foreground leading-relaxed transition-all duration-200',
isExpanded ? '' : 'line-clamp-2'
)}
>
{entry.description}
</p>
</div>
{isExpanded && (
<div className="space-y-3 pt-3 border-t border-border/30 animate-in slide-in-from-top-2 duration-200">
{entry.author && (
<div className="flex items-center gap-2 text-xs">
<div className="p-1 rounded bg-muted/40">
<Users className="h-3 w-3 text-muted-foreground" />
</div>
<div>
<span className="text-muted-foreground/70">
by{' '}
</span>
<span className="font-medium text-foreground">
{entry.author}
</span>
</div>
</div>
)}
{entry.tags &&
entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{entry.tags.map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs px-2 py-0.5 text-muted-foreground border-border/40 bg-muted/20 font-normal"
>
{tag}
</Badge>
))}
</div>
)}
{entry.homepage && (
<a
href={entry.homepage}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
onClick={(e) =>
e.stopPropagation()
}
>
<ExternalLink className="h-3 w-3" />
Documentation
</a>
)}
</div>
)}
</div>
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,688 @@
import React, { useState, useEffect } from 'react';
import {
useSessions,
useCreateSession,
useDeleteSession,
useRenameSession,
type Session,
} from './hooks/useSessions';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from './ui/dialog';
import { ScrollArea } from './ui/scroll-area';
import {
Trash2,
AlertTriangle,
RefreshCw,
History,
Search,
Plus,
MoreHorizontal,
Pencil,
Copy,
Check,
ChevronLeft,
Settings,
FlaskConical,
Moon,
Sun,
} from 'lucide-react';
import { Alert, AlertDescription } from './ui/alert';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from './ui/dropdown-menu';
import { cn } from '@/lib/utils';
interface SessionPanelProps {
isOpen: boolean;
onClose: () => void;
onExpand?: () => void;
currentSessionId?: string | null;
onSessionChange: (sessionId: string) => void;
returnToWelcome: () => void;
variant?: 'inline' | 'overlay';
onSearchOpen?: () => void;
onNewChat?: () => void;
// App-level actions
onSettingsOpen?: () => void;
onPlaygroundOpen?: () => void;
onThemeToggle?: () => void;
theme?: 'light' | 'dark';
}
function sortSessions(sessions: Session[]): Session[] {
return sessions.sort((a, b) => {
const timeA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const timeB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
return timeB - timeA;
});
}
export default function SessionPanel({
isOpen,
onClose,
onExpand,
currentSessionId,
onSessionChange,
returnToWelcome,
variant = 'overlay',
onSearchOpen,
onNewChat,
onSettingsOpen,
onPlaygroundOpen,
onThemeToggle,
theme,
}: SessionPanelProps) {
const [isNewSessionOpen, setNewSessionOpen] = useState(false);
const [newSessionId, setNewSessionId] = useState('');
const [isDeleteConversationDialogOpen, setDeleteConversationDialogOpen] = useState(false);
const [selectedSessionForAction, setSelectedSessionForAction] = useState<string | null>(null);
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [copiedSessionId, setCopiedSessionId] = useState<string | null>(null);
const { data: sessionsData = [], isLoading: loading, error } = useSessions(isOpen);
// Sort sessions by last activity for display
const sessions = sortSessions([...sessionsData]);
const createSessionMutation = useCreateSession();
const deleteSessionMutation = useDeleteSession();
const renameSessionMutation = useRenameSession();
// Note: Agent switch invalidation is now handled centrally in AgentSelector
// Message/response/title events are handled in useChat via direct cache updates
const handleCreateSession = async () => {
const newSession = await createSessionMutation.mutateAsync({
sessionId: newSessionId.trim() || undefined,
});
setNewSessionId('');
setNewSessionOpen(false);
onSessionChange(newSession.id);
};
const handleDeleteSession = async (sessionId: string) => {
await deleteSessionMutation.mutateAsync({ sessionId });
const isDeletingCurrentSession = currentSessionId === sessionId;
if (isDeletingCurrentSession) {
returnToWelcome();
}
};
const handleDeleteConversation = async () => {
if (!selectedSessionForAction) return;
await deleteSessionMutation.mutateAsync({ sessionId: selectedSessionForAction });
const isDeletingCurrentSession = currentSessionId === selectedSessionForAction;
if (isDeletingCurrentSession) {
returnToWelcome();
}
setDeleteConversationDialogOpen(false);
setSelectedSessionForAction(null);
};
const handleOpenRenameDialog = (sessionId: string, currentTitle: string | null) => {
setSelectedSessionForAction(sessionId);
setRenameValue(currentTitle || '');
setRenameDialogOpen(true);
};
const handleRenameSession = async () => {
if (!selectedSessionForAction || !renameValue.trim()) return;
try {
await renameSessionMutation.mutateAsync({
sessionId: selectedSessionForAction,
title: renameValue.trim(),
});
setRenameDialogOpen(false);
setSelectedSessionForAction(null);
setRenameValue('');
} catch (error) {
// Error is already logged by React Query, keep dialog open for retry
console.error(`Failed to rename session: ${error}`);
}
};
const handleCopySessionId = async (sessionId: string) => {
try {
await navigator.clipboard.writeText(sessionId);
setCopiedSessionId(sessionId);
} catch (error) {
console.error(`Failed to copy session ID: ${error}`);
}
};
// Clean up copy feedback timeout
useEffect(() => {
if (copiedSessionId) {
const timeoutId = setTimeout(() => setCopiedSessionId(null), 2000);
return () => clearTimeout(timeoutId);
}
}, [copiedSessionId]);
const formatRelativeTime = (timestamp: number | null) => {
if (!timestamp) return 'Unknown';
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const content = (
<div className="flex flex-col h-full">
{/* Header with Dexto branding */}
<div className="px-4 py-5">
<div className="flex items-center justify-between">
{/* Dexto logo */}
<div id="sessionpanel-title" className="flex items-center px-2">
{/* Light mode logo */}
<img
src="/logos/dexto/dexto_logo_light.svg"
alt="Dexto"
className="h-6 w-auto dark:hidden"
/>
{/* Dark mode logo */}
<img
src="/logos/dexto/dexto_logo.svg"
alt="Dexto"
className="h-6 w-auto hidden dark:block"
/>
</div>
{/* Collapse button */}
<button
onClick={onClose}
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label="Collapse panel"
>
<ChevronLeft className="h-5 w-5" />
</button>
</div>
</div>
{/* Error Display */}
{error && (
<div className="p-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error.message}</AlertDescription>
</Alert>
</div>
)}
{/* Sessions List */}
<ScrollArea className="flex-1 scrollbar-thin">
{/* Action items at top of list */}
<div className="px-3 pt-2 pb-1 space-y-0.5">
{onNewChat && (
<button
onClick={onNewChat}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
>
<Plus className="h-4 w-4" />
<span>New Chat</span>
</button>
)}
{onSearchOpen && (
<button
onClick={onSearchOpen}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
>
<Search className="h-4 w-4" />
<span>Search</span>
</button>
)}
</div>
{/* Spacer */}
{(onNewChat || onSearchOpen) && <div className="h-2" />}
{/* History Header */}
{!loading && sessions.length > 0 && (
<div className="px-4 py-2">
<h2 className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
History
</h2>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 px-6">
<History className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No conversations yet</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Start chatting to see your history
</p>
</div>
) : (
<div className="px-3 py-2 space-y-0.5">
{sessions.map((session) => {
const title =
session.title && session.title.trim().length > 0
? session.title
: session.id;
const isActive = currentSessionId === session.id;
return (
<div
key={session.id}
className={cn(
'group relative px-3 py-1.5 rounded-lg transition-all cursor-pointer',
isActive
? 'bg-primary/5 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:bg-primary before:rounded-full'
: 'hover:bg-muted/40'
)}
role="button"
tabIndex={0}
aria-current={isActive ? 'page' : undefined}
onClick={() => onSessionChange(session.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSessionChange(session.id);
}
}}
>
<div className="flex items-center justify-between gap-2">
<h3
className={cn(
'text-sm truncate flex-1 min-w-0',
isActive
? 'font-medium text-foreground'
: 'text-muted-foreground'
)}
>
{title}
</h3>
{/* Timestamp - hidden on hover */}
<span className="text-[10px] text-muted-foreground/50 shrink-0 group-hover:opacity-0 transition-opacity">
{formatRelativeTime(session.lastActivity)}
</span>
{/* Dropdown - shown on hover, positioned to overlap timestamp */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => e.stopPropagation()}
className="h-7 w-7 p-0 absolute right-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
aria-label="Session options"
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() =>
handleOpenRenameDialog(
session.id,
session.title ?? null
)
}
>
<Pencil className="h-4 w-4 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleCopySessionId(session.id)}
>
{copiedSessionId === session.id ? (
<Check className="h-4 w-4 mr-2 text-green-500" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{copiedSessionId === session.id
? 'Copied!'
: 'Copy Session ID'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (session.messageCount > 0) {
setSelectedSessionForAction(session.id);
setDeleteConversationDialogOpen(true);
} else {
handleDeleteSession(session.id);
}
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
{/* Footer with app-level actions */}
<div className="border-t border-border/30 p-3 space-y-1">
{/* Developer Tools */}
{onPlaygroundOpen && (
<button
onClick={onPlaygroundOpen}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
>
<FlaskConical className="h-4 w-4" />
<span>MCP Playground</span>
</button>
)}
{/* Separator */}
{onPlaygroundOpen && (onThemeToggle || onSettingsOpen) && (
<div className="h-px bg-border/30 my-1" />
)}
{/* Theme Toggle */}
{onThemeToggle && (
<button
onClick={onThemeToggle}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
>
{theme === 'dark' ? (
<Sun className="h-4 w-4 transition-transform duration-200 hover:rotate-180" />
) : (
<Moon className="h-4 w-4 transition-transform duration-200 hover:rotate-12" />
)}
<span>{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
</button>
)}
{/* Settings */}
{onSettingsOpen && (
<button
onClick={onSettingsOpen}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
>
<Settings className="h-4 w-4" />
<span>Settings</span>
</button>
)}
</div>
{/* New Chat Dialog */}
<Dialog open={isNewSessionOpen} onOpenChange={setNewSessionOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Start New Chat</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sessionId">Chat ID</Label>
<Input
id="sessionId"
value={newSessionId}
onChange={(e) => setNewSessionId(e.target.value)}
placeholder="e.g., user-123, project-alpha"
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
Leave empty to auto-generate a unique ID
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewSessionOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateSession}>Start Chat</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Conversation Confirmation Dialog */}
<Dialog
open={isDeleteConversationDialogOpen}
onOpenChange={setDeleteConversationDialogOpen}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Conversation</span>
</DialogTitle>
<DialogDescription>
This will permanently delete this conversation and all its messages.
This action cannot be undone.
{selectedSessionForAction && (
<span className="block mt-2 font-medium">
Session:{' '}
<span className="font-mono">{selectedSessionForAction}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteConversationDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConversation}
disabled={
deleteSessionMutation.isPending &&
deleteSessionMutation.variables?.sessionId ===
selectedSessionForAction
}
className="flex items-center space-x-2"
>
<Trash2 className="h-4 w-4" />
<span>
{deleteSessionMutation.isPending &&
deleteSessionMutation.variables?.sessionId ===
selectedSessionForAction
? 'Deleting...'
: 'Delete Conversation'}
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Session Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Pencil className="h-5 w-5" />
<span>Rename Chat</span>
</DialogTitle>
<DialogDescription>
Enter a new name for this conversation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="renameTitle">Chat Name</Label>
<Input
id="renameTitle"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Enter chat name..."
onKeyDown={(e) => {
if (e.key === 'Enter' && renameValue.trim()) {
handleRenameSession();
}
}}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleRenameSession}
disabled={!renameValue.trim() || renameSessionMutation.isPending}
>
{renameSessionMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
// Collapsed sidebar content - thin bar with icon buttons
const collapsedContent = (
<div className="flex flex-col h-full py-3 px-2 items-center">
{/* Dexto icon - click to expand */}
<button
onClick={onExpand}
className="flex items-center justify-center w-10 h-10 rounded-lg hover:bg-muted/40 transition-colors mb-3"
aria-label="Expand panel"
>
<img src="/logos/dexto/dexto_logo_icon.svg" alt="Dexto" className="h-7 w-7" />
</button>
{/* Action items with subtle spacing */}
<div className="flex flex-col gap-1 flex-1">
{/* New Chat */}
{onNewChat && (
<button
onClick={onNewChat}
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label="New chat"
>
<Plus className="h-5 w-5" />
</button>
)}
{/* Search */}
{onSearchOpen && (
<button
onClick={onSearchOpen}
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label="Search"
>
<Search className="h-5 w-5" />
</button>
)}
</div>
{/* Footer actions - playground, theme, settings */}
<div className="flex flex-col gap-1 pt-2 border-t border-border/30">
{/* Playground */}
{onPlaygroundOpen && (
<button
onClick={onPlaygroundOpen}
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label="MCP Playground"
>
<FlaskConical className="h-5 w-5" />
</button>
)}
{/* Theme Toggle */}
{onThemeToggle && (
<button
onClick={onThemeToggle}
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
)}
{/* Settings */}
{onSettingsOpen && (
<button
onClick={onSettingsOpen}
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:bg-muted/40 hover:text-foreground transition-colors"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>
)}
</div>
</div>
);
// For inline variant, show collapsed or expanded
if (variant === 'inline') {
if (!isOpen) {
// Collapsed state - thin bar
return (
<div className="h-full flex flex-col bg-card border-r border-border/30">
{collapsedContent}
</div>
);
}
// Expanded state - full panel
return <div className="h-full w-full flex flex-col bg-card">{content}</div>;
}
// Overlay variant with slide animation
return (
<>
{/* Backdrop */}
<div
className={cn(
'fixed inset-0 bg-black/50 z-30 transition-opacity duration-300',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
/>
{/* Panel */}
<aside
role="dialog"
aria-modal="true"
aria-labelledby="sessionpanel-title"
tabIndex={-1}
className={cn(
'fixed top-0 left-0 z-40 h-screen w-72 bg-card border-r border-border shadow-xl transition-transform duration-300 ease-in-out flex flex-col',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{content}
</aside>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Label } from './ui/label';
import { SpeechVoiceSelect } from './ui/speech-voice-select';
type SettingsModalProps = {
isOpen: boolean;
onClose: () => void;
};
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Configure preferences for speech and more.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<section className="space-y-2">
<Label className="text-xs uppercase text-muted-foreground">Voice</Label>
<p className="text-xs text-muted-foreground">
Choose a preferred text-to-speech voice. Auto selects the best
available voice on your device.
</p>
<SpeechVoiceSelect active={isOpen} />
</section>
{/* TODO: Future settings (e.g., streaming, theme, hotkeys) */}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,436 @@
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, Zap, Plus } from 'lucide-react';
import { Badge } from './ui/badge';
import type { PromptInfo as CorePromptInfo } from '@dexto/core';
import { usePrompts } from './hooks/usePrompts';
// Use canonical types from @dexto/core for alignment
type PromptInfo = CorePromptInfo;
// PromptItem component for rendering individual prompts
const PromptItem = ({
prompt,
isSelected,
onClick,
onMouseEnter,
dataIndex,
}: {
prompt: Prompt;
isSelected: boolean;
onClick: () => void;
onMouseEnter?: () => void;
dataIndex?: number;
}) => (
<div
className={`px-3 py-2 cursor-pointer transition-colors ${
isSelected ? 'bg-primary/20 ring-1 ring-primary/40' : 'hover:bg-primary/10'
}`}
onClick={onClick}
onMouseEnter={onMouseEnter}
data-index={dataIndex}
>
<div className="flex items-start gap-2">
<div className="flex-shrink-0 mt-0.5">
{prompt.source === 'mcp' ? (
<Zap className="h-3 w-3 text-blue-400" />
) : prompt.source === 'config' ? (
<span className="text-xs">📋</span>
) : (
<Sparkles className="h-3 w-3 text-purple-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{/* Command name with inline arguments */}
<div className="flex items-center gap-1">
{/* Use commandName (collision-resolved) for display, fall back to displayName/name */}
<span className="font-medium text-xs text-foreground">
/{prompt.commandName || prompt.displayName || prompt.name}
</span>
{prompt.arguments && prompt.arguments.length > 0 && (
<span className="flex items-center gap-1">
{prompt.arguments.map((arg) => (
<span
key={arg.name}
className="group relative inline-block"
title={arg.description || arg.name}
>
<span className="text-xs text-muted-foreground/70 hover:text-muted-foreground cursor-help transition-colors">
&lt;{arg.name}
{arg.required ? '' : '?'}&gt;
</span>
{/* Tooltip on hover */}
{arg.description && (
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 z-50 px-2 py-1 text-[10px] bg-popover text-popover-foreground border border-border rounded shadow-lg whitespace-nowrap pointer-events-none">
{arg.description}
</span>
)}
</span>
))}
</span>
)}
</div>
{/* Source badges */}
{prompt.source === 'mcp' && (
<Badge variant="outline" className="text-xs px-1.5 py-0.5 h-4">
MCP
</Badge>
)}
{prompt.source === 'config' && (
<Badge variant="outline" className="text-xs px-1.5 py-0.5 h-4">
Config
</Badge>
)}
{prompt.source === 'custom' && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0.5 h-4 bg-primary/10 text-primary border-primary/20"
>
Custom
</Badge>
)}
</div>
{/* Show title if available */}
{prompt.title && (
<div className="text-xs font-medium text-foreground/90 mb-0.5">
{prompt.title}
</div>
)}
{/* Show description if available and different from title */}
{prompt.description && prompt.description !== prompt.title && (
<div className="text-xs text-muted-foreground mb-1.5 line-clamp-2">
{prompt.description}
</div>
)}
</div>
</div>
</div>
);
// Define UI-specific Prompt interface extending core PromptInfo
interface Prompt extends PromptInfo {
// UI-specific fields that may come from metadata
starterPrompt?: boolean;
category?: string;
icon?: string;
priority?: number;
}
interface SlashCommandAutocompleteProps {
isVisible: boolean;
searchQuery: string;
onSelectPrompt: (prompt: Prompt) => void;
onClose: () => void;
onCreatePrompt?: () => void;
refreshKey?: number;
}
export default function SlashCommandAutocomplete({
isVisible,
searchQuery,
onSelectPrompt,
onClose,
onCreatePrompt,
refreshKey,
}: SlashCommandAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedIndexRef = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastRefreshKeyRef = useRef<number>(0);
// Fetch prompts using TanStack Query
const { data: prompts = [], isLoading, refetch } = usePrompts({ enabled: isVisible });
// Keep the latest selected index accessible in callbacks without needing extra effect deps
selectedIndexRef.current = selectedIndex;
// Refetch when refreshKey changes
useEffect(() => {
if (!isVisible) return;
const effectiveKey = refreshKey ?? 0;
if (effectiveKey > 0 && effectiveKey !== lastRefreshKeyRef.current) {
refetch();
lastRefreshKeyRef.current = effectiveKey;
}
}, [isVisible, refreshKey, refetch]);
// Filter prompts based on search query - memoized to avoid infinite loops
const filteredPrompts = React.useMemo(() => {
if (!searchQuery.trim() || searchQuery === '/') {
return prompts;
}
// Extract just the command name (first word after /) for filtering
// E.g., "/summarize technical 100 'text'" -> "summarize"
const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery;
const commandName = withoutSlash.split(/\s+/)[0] || '';
return prompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(commandName.toLowerCase()) ||
(prompt.description &&
prompt.description.toLowerCase().includes(commandName.toLowerCase())) ||
(prompt.title && prompt.title.toLowerCase().includes(commandName.toLowerCase()))
);
}, [searchQuery, prompts]);
const showCreateOption = React.useMemo(() => {
const trimmed = searchQuery.trim();
if (!trimmed) return false;
if (trimmed === '/') return true;
if (trimmed.startsWith('/') && filteredPrompts.length === 0) return true;
return false;
}, [searchQuery, filteredPrompts.length]);
const combinedItems = React.useMemo(() => {
const items: Array<{ kind: 'create' } | { kind: 'prompt'; prompt: Prompt }> = [];
if (showCreateOption) {
items.push({ kind: 'create' });
}
filteredPrompts.forEach((prompt) => items.push({ kind: 'prompt', prompt }));
return items;
}, [showCreateOption, filteredPrompts]);
// Note: mcp:prompts-list-changed DOM listener removed (was dead code - never dispatched as DOM event)
// Prompts are refreshed via React Query's built-in mechanisms when needed
// Reset selected index when filtered results change
useEffect(() => {
const shouldShowCreate = searchQuery === '/';
const defaultIndex = shouldShowCreate && filteredPrompts.length > 0 ? 1 : 0;
setSelectedIndex(defaultIndex);
}, [searchQuery, filteredPrompts.length]);
const itemsLength = combinedItems.length;
useEffect(() => {
setSelectedIndex((prevIndex) => {
if (itemsLength === 0) {
return 0;
}
if (prevIndex >= itemsLength) {
return itemsLength - 1;
}
return prevIndex;
});
}, [itemsLength]);
// Handle keyboard navigation
useEffect(() => {
if (!isVisible) return;
const handleKeyDown = (e: KeyboardEvent) => {
const items = combinedItems;
const stop = () => {
e.preventDefault();
e.stopPropagation();
// Some environments support stopImmediatePropagation on DOM events
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
};
// Check if user has typed arguments after the command name
// E.g., "/summarize technical 100 'text'" -> has arguments, so Enter should submit
const withoutSlash = searchQuery.startsWith('/') ? searchQuery.slice(1) : searchQuery;
const parts = withoutSlash.split(/\s+/);
const hasArguments =
parts.length > 1 && parts.slice(1).some((p) => p.trim().length > 0);
switch (e.key) {
case 'ArrowDown':
if (items.length === 0) return;
stop();
setSelectedIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
if (items.length === 0) return;
stop();
setSelectedIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Enter':
// If user has typed arguments, let Enter pass through to submit the message
if (hasArguments) {
return; // Don't intercept - let InputArea handle submission
}
stop();
if (items.length === 0) {
onCreatePrompt?.();
return;
}
{
const item = items[selectedIndexRef.current];
if (item.kind === 'create') {
onCreatePrompt?.();
} else {
onSelectPrompt(item.prompt);
}
}
break;
case 'Escape':
stop();
onClose();
break;
case 'Tab':
stop();
if (items.length === 0) {
onCreatePrompt?.();
return;
}
{
const item = items[selectedIndexRef.current];
if (item.kind === 'create') {
onCreatePrompt?.();
} else {
onSelectPrompt(item.prompt);
}
}
break;
}
};
// Use capture phase so we can intercept Enter before input handlers stop propagation
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [isVisible, combinedItems, onSelectPrompt, onClose, onCreatePrompt, searchQuery]);
// Scroll selected item into view when selectedIndex changes
useEffect(() => {
if (!scrollContainerRef.current) return;
const scrollContainer = scrollContainerRef.current;
const selectedItem = scrollContainer.querySelector(
`[data-index="${selectedIndex}"]`
) as HTMLElement;
if (selectedItem) {
const containerRect = scrollContainer.getBoundingClientRect();
const itemRect = selectedItem.getBoundingClientRect();
// Check if item is visible in container
const isAbove = itemRect.top < containerRect.top;
const isBelow = itemRect.bottom > containerRect.bottom;
if (isAbove || isBelow) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
}
}, [selectedIndex]);
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isVisible, onClose]);
if (!isVisible) return null;
return (
<div
ref={containerRef}
className="absolute left-0 right-0 mb-2 bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-hidden z-[9999]"
style={{
position: 'absolute',
bottom: 'calc(100% + 0px)',
left: 0,
right: 0,
borderRadius: '8px',
maxHeight: '320px',
overflow: 'visible',
zIndex: 9999,
minWidth: '400px',
// Custom dark styling
background:
'linear-gradient(135deg, hsl(var(--background)) 0%, hsl(var(--muted)) 100%)',
border: '1px solid hsl(var(--border) / 0.3)',
backdropFilter: 'blur(8px)',
boxShadow:
'0 8px 32px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
}}
>
{/* Header - Compact with prompt count */}
<div className="px-3 py-2 border-b border-border bg-muted/50">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span>Available Prompts (hover over arguments for more info)</span>
<Badge variant="secondary" className="ml-auto text-xs px-2 py-0.5">
{prompts.length}
</Badge>
</div>
</div>
{/* Prompts List */}
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto">
{isLoading ? (
<div className="p-3 text-center text-xs text-muted-foreground">
Loading prompts...
</div>
) : (
<>
{showCreateOption && (
<div
className={`px-3 py-2 cursor-pointer transition-colors ${
selectedIndex === 0
? 'bg-primary/20 ring-1 ring-primary/40'
: 'hover:bg-primary/10'
}`}
onClick={() => onCreatePrompt?.()}
onMouseEnter={() => setSelectedIndex(0)}
data-index={0}
>
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
<Plus className="h-3 w-3 text-primary" />
<span>Create new prompt</span>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Define a reusable prompt. Press Enter to continue.
</div>
</div>
)}
{filteredPrompts.length === 0
? !showCreateOption && (
<div className="p-3 text-center text-xs text-muted-foreground">
No prompts available.
</div>
)
: filteredPrompts.map((prompt, index) => {
const itemIndex = showCreateOption ? index + 1 : index;
return (
<PromptItem
key={prompt.name}
prompt={prompt}
isSelected={itemIndex === selectedIndex}
onClick={() => onSelectPrompt(prompt)}
onMouseEnter={() => setSelectedIndex(itemIndex)}
dataIndex={itemIndex}
/>
);
})}
</>
)}
</div>
{/* Footer - Compact with navigation hints */}
<div className="px-2 py-1.5 border-t border-border bg-muted/20 text-xs text-muted-foreground text-center">
<span> Navigate Tab/Enter Select Esc Close</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import * as Switch from '@radix-ui/react-switch';
import { Sun, Moon } from 'lucide-react';
import { useTheme } from './hooks/useTheme';
import { useState, useEffect } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
export function ThemeSwitch() {
const { theme, toggleTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const isDark = theme === 'dark';
// Don't render switch until after hydration to avoid mismatch
if (!mounted) {
return <div className="w-12 h-6 bg-gray-300 dark:bg-gray-700 rounded-full" />;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Switch.Root
checked={isDark}
onCheckedChange={toggleTheme}
className="w-12 h-6 bg-gray-300 dark:bg-gray-700 rounded-full relative transition-colors flex items-center px-0.5"
aria-label="Toggle theme"
>
<Switch.Thumb
className={`
w-5 h-5 rounded-full shadow flex items-center justify-center
transition-transform transform
translate-x-0.5 data-[state=checked]:translate-x-[1.375rem]
bg-white dark:bg-gray-100
`}
>
{isDark ? (
<Moon className="w-3.5 h-3.5 text-gray-700" />
) : (
<Sun className="w-3.5 h-3.5 text-yellow-500" />
)}
</Switch.Thumb>
</Switch.Root>
</TooltipTrigger>
<TooltipContent>
{isDark ? 'Switch to light mode' : 'Switch to dark mode'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,144 @@
/**
* Toast Container
*
* Displays toast notifications in the bottom-right corner.
* Handles auto-dismiss, manual dismiss, and "Go to session" actions.
*/
'use client';
import { useEffect, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationStore, type Toast } from '@/lib/stores/notificationStore';
import { useSessionStore } from '@/lib/stores/sessionStore';
import { X, AlertTriangle, CheckCircle2, Info, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Intent color mappings
*/
const intentStyles = {
info: {
bg: 'bg-blue-50 dark:bg-blue-950/50',
border: 'border-blue-200 dark:border-blue-800',
icon: 'text-blue-600 dark:text-blue-400',
iconComponent: Info,
},
success: {
bg: 'bg-green-50 dark:bg-green-950/50',
border: 'border-green-200 dark:border-green-800',
icon: 'text-green-600 dark:text-green-400',
iconComponent: CheckCircle2,
},
warning: {
bg: 'bg-yellow-50 dark:bg-yellow-950/50',
border: 'border-yellow-200 dark:border-yellow-800',
icon: 'text-yellow-600 dark:text-yellow-400',
iconComponent: AlertTriangle,
},
danger: {
bg: 'bg-red-50 dark:bg-red-950/50',
border: 'border-red-200 dark:border-red-800',
icon: 'text-red-600 dark:text-red-400',
iconComponent: AlertCircle,
},
};
/**
* Individual toast item component
*/
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const navigate = useNavigate();
const { setCurrentSession } = useSessionStore();
// Auto-dismiss after duration
useEffect(() => {
const duration = toast.duration || 5000;
const timer = setTimeout(onDismiss, duration);
return () => clearTimeout(timer);
}, [toast.duration, onDismiss]);
// Navigate to session
const handleGoToSession = useCallback(() => {
if (toast.sessionId) {
setCurrentSession(toast.sessionId);
navigate({ to: '/' });
onDismiss();
}
}, [toast.sessionId, setCurrentSession, navigate, onDismiss]);
const styles = intentStyles[toast.intent];
const IconComponent = styles.iconComponent;
return (
<div
className={cn(
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
'min-w-[320px] max-w-[420px]',
'animate-in slide-in-from-right-full duration-300',
styles.bg,
styles.border
)}
role="alert"
aria-live="polite"
>
{/* Icon */}
<IconComponent className={cn('w-5 h-5 flex-shrink-0 mt-0.5', styles.icon)} />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{toast.title}
</div>
{toast.description && (
<div className="text-sm text-gray-700 dark:text-gray-300 mt-1">
{toast.description}
</div>
)}
{toast.sessionId && (
<button
onClick={handleGoToSession}
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
Go to session
</button>
)}
</div>
{/* Dismiss button */}
<button
onClick={onDismiss}
className="flex-shrink-0 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
/**
* Toast container component
*
* Renders all active toasts in a fixed bottom-right position.
*/
export function ToastContainer() {
const { toasts, removeToast } = useNotificationStore();
if (toasts.length === 0) {
return null;
}
return (
<div
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
aria-label="Notifications"
>
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onDismiss={() => removeToast(toast.id)} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
/**
* Toast Components
*
* Barrel export for toast notification components.
*/
export { ToastContainer } from './ToastContainer.js';

View File

@@ -0,0 +1,107 @@
'use client';
/**
* TodoPanel Component (WebUI)
* Displays agent's todo list with progress tracking
*/
import React from 'react';
import { CheckCircle2, Circle } from 'lucide-react';
import { Card } from './ui/card';
import { cn } from '@/lib/utils';
import { useTodoStore, type Todo } from '@/lib/stores/todoStore';
interface TodoPanelProps {
sessionId: string;
}
// Stable empty array to avoid infinite re-render loop
const EMPTY_TODOS: Todo[] = [];
/**
* Compact todo panel showing task progress
* Shows up to 10 tasks with minimal spacing
*/
export function TodoPanel({ sessionId }: TodoPanelProps) {
// Select directly from state, use stable empty array fallback outside selector
const todos = useTodoStore((state) => state.sessions.get(sessionId)?.todos) ?? EMPTY_TODOS;
if (todos.length === 0) {
return null;
}
const completedCount = todos.filter((t) => t.status === 'completed').length;
const totalCount = todos.length;
// Show up to 10 tasks total
const visibleTodos = todos.slice(0, 10);
const hasMore = todos.length > 10;
return (
<Card className="border-l-4 border-l-amber-500 dark:border-l-amber-600 border-t border-r border-b border-border bg-card/50 backdrop-blur-sm shadow-sm">
<div className="p-3 space-y-2.5">
{/* Header with progress */}
<div className="flex items-center justify-between border-b border-amber-200 dark:border-amber-900/50 pb-2">
<span className="text-sm font-semibold text-foreground tracking-tight">
Tasks in Progress
</span>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-16 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-500 to-orange-500 dark:from-amber-600 dark:to-orange-600 transition-all duration-300"
style={{
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
}}
/>
</div>
<span className="text-xs font-medium text-muted-foreground tabular-nums">
{completedCount}/{totalCount}
</span>
</div>
</div>
{/* All tasks */}
<div className="space-y-1.5">
{visibleTodos.map((todo) => {
const isInProgress = todo.status === 'in_progress';
const isCompleted = todo.status === 'completed';
return (
<div key={todo.id} className="flex items-start gap-2.5 group">
<div className="mt-0.5">
{isCompleted ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
) : isInProgress ? (
<div className="h-3.5 w-3.5 rounded-full border-2 border-amber-500 dark:border-amber-600 bg-amber-500/20 dark:bg-amber-600/20 flex items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-amber-500 dark:bg-amber-600" />
</div>
) : (
<Circle className="h-3.5 w-3.5 text-muted-foreground/40" />
)}
</div>
<span
className={cn(
'text-sm leading-relaxed flex-1',
isCompleted && 'line-through text-muted-foreground/60',
isInProgress && 'text-foreground font-medium',
!isCompleted && !isInProgress && 'text-muted-foreground'
)}
>
{isInProgress ? todo.activeForm : todo.content}
</span>
</div>
);
})}
</div>
{/* More tasks indicator */}
{hasMore && (
<div className="pt-1 border-t border-border/50">
<span className="text-xs text-muted-foreground/70 italic">
+{todos.length - 10} more tasks...
</span>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,936 @@
import { useState, useEffect } from 'react';
import {
ChevronRight,
CheckCircle2,
XCircle,
Loader2,
AlertCircle,
Shield,
FileText,
FileEdit,
FilePlus,
Trash2,
Terminal,
Search,
Copy,
Check,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from './ui/button';
import { CodePreview } from './CodePreview';
import type { ToolDisplayData } from '@dexto/core';
/**
* Sub-agent progress data for spawn_agent tool calls
*/
export interface SubAgentProgress {
task: string;
agentId: string;
toolsCalled: number;
currentTool: string;
currentArgs?: Record<string, unknown>;
}
export interface ToolCallTimelineProps {
toolName: string;
toolArgs?: Record<string, unknown>;
toolResult?: unknown;
success?: boolean;
requireApproval?: boolean;
approvalStatus?: 'pending' | 'approved' | 'rejected';
displayData?: ToolDisplayData;
subAgentProgress?: SubAgentProgress;
onApprove?: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
onReject?: () => void;
}
// =============================================================================
// Helpers
// =============================================================================
function stripToolPrefix(toolName: string): { displayName: string; source: string } {
if (toolName.startsWith('internal--')) {
return { displayName: toolName.replace('internal--', ''), source: '' };
}
if (toolName.startsWith('custom--')) {
return { displayName: toolName.replace('custom--', ''), source: '' };
}
if (toolName.startsWith('mcp--')) {
const parts = toolName.split('--');
if (parts.length >= 3) {
return { displayName: parts.slice(2).join('--'), source: parts[1] ?? '' };
}
return { displayName: toolName.replace('mcp--', ''), source: 'mcp' };
}
if (toolName.startsWith('mcp__')) {
const parts = toolName.substring(5).split('__');
if (parts.length >= 2) {
return { displayName: parts.slice(1).join('__'), source: parts[0] ?? '' };
}
return { displayName: toolName.substring(5), source: 'mcp' };
}
if (toolName.startsWith('internal__')) {
return { displayName: toolName.substring(10), source: '' };
}
return { displayName: toolName, source: '' };
}
function getShortPath(path: string): string {
const parts = path.split('/').filter(Boolean);
if (parts.length <= 2) return path;
return `.../${parts.slice(-2).join('/')}`;
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
function getSummary(
displayName: string,
toolArgs?: Record<string, unknown>
): { name: string; detail?: string } {
const args = toolArgs || {};
const filePath = (args.file_path || args.path || args.file) as string | undefined;
const command = args.command as string | undefined;
const pattern = (args.pattern || args.query) as string | undefined;
if (command) {
return { name: displayName, detail: truncate(command, 40) };
}
if (filePath) {
return { name: displayName, detail: getShortPath(filePath) };
}
if (pattern) {
return { name: displayName, detail: `"${truncate(pattern, 25)}"` };
}
return { name: displayName };
}
// =============================================================================
// Main Component
// =============================================================================
export function ToolCallTimeline({
toolName,
toolArgs,
toolResult,
success,
requireApproval = false,
approvalStatus,
displayData,
subAgentProgress,
onApprove,
onReject,
}: ToolCallTimelineProps) {
const hasResult = toolResult !== undefined;
const isPendingApproval = requireApproval && approvalStatus === 'pending';
const isFailed = success === false;
const isRejected = approvalStatus === 'rejected';
// Tool is processing only if: no result yet, not pending approval, and not marked as failed
// The `success === false` check handles incomplete tool calls from history (never got a result)
const isProcessing = !hasResult && !isPendingApproval && !isFailed;
const hasSubAgentProgress = !!subAgentProgress;
// Determine if there's meaningful content to show
const hasExpandableContent = Boolean(
displayData ||
toolArgs?.content ||
(toolArgs?.old_string && toolArgs?.new_string) ||
(toolArgs?.command && hasResult)
);
// Determine if this tool has rich UI that should be shown by default
// Rich UI includes: displayData, file content previews, and diff views
// Exclude bash commands as they're more variable in visual value
const hasRichUI = Boolean(
displayData || toolArgs?.content || (toolArgs?.old_string && toolArgs?.new_string)
);
// Smart default: expand for pending approvals and successful tools with rich UI
// Failed, rejected, and no-output should always be collapsed
const [expanded, setExpanded] = useState(
isPendingApproval || (hasRichUI && !isFailed && !isRejected)
);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
// Auto-collapse after approval is resolved, but keep open if tool has rich UI and succeeded
useEffect(() => {
if (requireApproval && approvalStatus && approvalStatus !== 'pending') {
// Collapse if rejected or if no rich UI to show
if (isRejected || !hasRichUI) {
setExpanded(false);
}
}
}, [requireApproval, approvalStatus, hasRichUI, isRejected]);
const { displayName, source } = stripToolPrefix(toolName);
const summary = getSummary(displayName, toolArgs);
// For sub-agent progress, format the agent name nicely
const subAgentLabel = hasSubAgentProgress
? subAgentProgress.agentId
.replace(/-agent$/, '')
.charAt(0)
.toUpperCase() + subAgentProgress.agentId.replace(/-agent$/, '').slice(1)
: null;
// Status icon
const StatusIcon = isPendingApproval ? (
<div className="relative">
<AlertCircle className="h-3.5 w-3.5 text-amber-500" />
<span className="absolute inset-0 rounded-full bg-amber-500/30 animate-ping" />
</div>
) : isProcessing ? (
<Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />
) : isFailed || isRejected ? (
<XCircle className="h-3.5 w-3.5 text-red-500" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
);
// Header click handler
const toggleExpanded = () => {
if (hasResult || isPendingApproval || hasExpandableContent) {
setExpanded(!expanded);
}
};
const canExpand = hasResult || isPendingApproval || hasExpandableContent;
return (
<div
className={cn(
'my-0.5 rounded-md transition-colors inline-block max-w-full',
isPendingApproval &&
'bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/30'
)}
>
{/* Collapsed Header - Always Visible */}
<button
onClick={toggleExpanded}
disabled={!canExpand}
className={cn(
'inline-flex items-center gap-2 p-1.5 text-left rounded-md',
canExpand && 'hover:bg-muted/40 cursor-pointer',
!canExpand && 'cursor-default'
)}
>
{/* Status icon */}
<div className="flex-shrink-0">{StatusIcon}</div>
{/* Summary text */}
<span
className={cn(
'text-xs flex-1 truncate',
isPendingApproval && 'text-amber-700 dark:text-amber-300 font-medium',
isProcessing && 'text-blue-600 dark:text-blue-400',
isFailed && 'text-red-600 dark:text-red-400',
isRejected && 'text-red-600 dark:text-red-400',
!isPendingApproval &&
!isProcessing &&
!isFailed &&
!isRejected &&
'text-foreground/70'
)}
>
{isPendingApproval ? 'Approval required: ' : ''}
{isFailed ? 'Failed: ' : ''}
{isRejected ? 'Rejected: ' : ''}
{hasSubAgentProgress ? (
<span className="font-mono">
<span className="text-purple-600 dark:text-purple-400 font-medium">
{subAgentLabel}
</span>
<span className="text-muted-foreground/50">(</span>
<span className="text-foreground/80">{subAgentProgress.task}</span>
<span className="text-muted-foreground/50">)</span>
</span>
) : (
<span className="font-mono">
<span className="text-blue-600 dark:text-blue-400">
{summary.name.toLowerCase()}
</span>
<span className="text-muted-foreground/50">(</span>
{summary.detail && (
<span className="text-foreground/80">{summary.detail}</span>
)}
<span className="text-muted-foreground/50">)</span>
</span>
)}
</span>
{/* Badges */}
{source && (
<span className="text-[10px] text-muted-foreground/60 flex-shrink-0">
[{source}]
</span>
)}
{requireApproval && approvalStatus === 'approved' && (
<span className="text-[10px] text-green-600 dark:text-green-500 flex-shrink-0">
approved
</span>
)}
{isProcessing && !hasSubAgentProgress && (
<span className="text-[10px] text-muted-foreground/50 flex-shrink-0">
running...
</span>
)}
{/* Sub-agent progress indicator */}
{hasSubAgentProgress && isProcessing && (
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{subAgentProgress.toolsCalled} tool
{subAgentProgress.toolsCalled !== 1 ? 's' : ''} |{' '}
{subAgentProgress.currentTool}
</span>
)}
{/* Expand chevron */}
{canExpand && (
<ChevronRight
className={cn(
'h-3 w-3 text-muted-foreground/40 flex-shrink-0 transition-transform',
expanded && 'rotate-90'
)}
/>
)}
</button>
{/* Expanded Content */}
{expanded && (
<div className="px-1.5 pb-2 pt-1 space-y-2 animate-fade-in">
{/* Pending Approval Content */}
{isPendingApproval && (
<>
{renderApprovalPreview()}
<div className="flex gap-1.5 flex-wrap pt-1">
<Button
onClick={() => onApprove?.(undefined, false)}
size="sm"
className="bg-green-600 hover:bg-green-700 text-white h-6 text-[11px] px-2.5"
>
Approve
</Button>
<Button
onClick={() => onApprove?.(undefined, true)}
size="sm"
variant="outline"
className="h-6 text-[11px] px-2 text-green-600 border-green-300 hover:bg-green-50 dark:border-green-700 dark:hover:bg-green-950/20"
>
<Shield className="h-3 w-3 mr-1" />
Always
</Button>
<Button
onClick={() => onReject?.()}
variant="outline"
size="sm"
className="h-6 text-[11px] px-2.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
>
Reject
</Button>
</div>
</>
)}
{/* Error Content */}
{isFailed && hasResult && renderErrorContent()}
{/* Result Content */}
{hasResult && !isFailed && !isPendingApproval && renderResultContent()}
</div>
)}
</div>
);
// =========================================================================
// Render Functions
// =========================================================================
function renderApprovalPreview() {
const command = toolArgs?.command as string | undefined;
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
const content = toolArgs?.content as string | undefined;
const oldString = toolArgs?.old_string as string | undefined;
const newString = toolArgs?.new_string as string | undefined;
// Bash command
if (command) {
return (
<div className="ml-5 bg-zinc-900 rounded overflow-hidden">
<pre className="px-2 py-1.5 text-[11px] text-zinc-300 font-mono whitespace-pre-wrap">
<span className="text-zinc-500">$ </span>
{command}
</pre>
</div>
);
}
// Edit operation - diff view without header (file path is in summary)
if (oldString !== undefined && newString !== undefined) {
return (
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
{oldString
.split('\n')
.slice(0, detailsExpanded ? 15 : 3)
.map((line, i) => (
<div
key={`o${i}`}
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
>
<span className="text-red-500/50 mr-1">-</span>
{line || ' '}
</div>
))}
{newString
.split('\n')
.slice(0, detailsExpanded ? 15 : 3)
.map((line, i) => (
<div
key={`n${i}`}
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
>
<span className="text-green-500/50 mr-1">+</span>
{line || ' '}
</div>
))}
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
>
{detailsExpanded ? 'less' : 'more...'}
</button>
)}
</div>
);
}
// Write/Create file
if (content && filePath) {
return (
<div className="ml-5">
<CodePreview
content={content}
filePath={filePath}
maxLines={8}
maxHeight={180}
showHeader={false}
/>
</div>
);
}
return null;
}
function renderErrorContent() {
let errorMessage = 'Unknown error';
if (toolResult && typeof toolResult === 'object') {
const result = toolResult as Record<string, unknown>;
if (result.content && Array.isArray(result.content)) {
const textPart = result.content.find(
(p: unknown) =>
typeof p === 'object' &&
p !== null &&
(p as Record<string, unknown>).type === 'text'
) as { text?: string } | undefined;
if (textPart?.text) errorMessage = textPart.text;
} else if (result.error) {
errorMessage =
typeof result.error === 'string' ? result.error : JSON.stringify(result.error);
}
}
return (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800/50 rounded px-2 py-1.5">
<pre className="text-[11px] text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-all">
{truncate(errorMessage, 500)}
</pre>
</div>
);
}
function renderResultContent() {
// Extract toolArgs for checking rich content availability
const command = toolArgs?.command as string | undefined;
const filePath = (toolArgs?.file_path || toolArgs?.path) as string | undefined;
const content = toolArgs?.content as string | undefined;
const oldString = toolArgs?.old_string as string | undefined;
const newString = toolArgs?.new_string as string | undefined;
// Check if we have rich content that should override simple displayData
const hasRichContent = !!(
content ||
(oldString !== undefined && newString !== undefined) ||
(command && hasResult)
);
// If we have display metadata from tool, use it (unless we have richer content)
if (displayData) {
// Skip simple file metadata display if we have rich content to show
if (displayData.type === 'file' && hasRichContent) {
// Fall through to render rich content below
} else {
switch (displayData.type) {
case 'diff':
return renderDiff(displayData);
case 'shell':
return renderShell(displayData);
case 'search':
return renderSearch(displayData);
case 'file':
return renderFile(displayData);
}
}
}
// Render rich content from toolArgs
// Bash command with result
if (command && hasResult) {
return renderBashResult(command);
}
// Edit operation (old_string -> new_string)
if (oldString !== undefined && newString !== undefined && filePath) {
return renderEditResult(filePath, oldString, newString);
}
// Write/create file
if (content && filePath) {
return renderWriteResult(filePath, content);
}
// Read file - show content from result
if (displayName.toLowerCase().includes('read') && filePath) {
return renderReadResult(filePath);
}
// Fallback to generic
return renderGenericResult();
}
function renderDiff(data: Extract<ToolDisplayData, { type: 'diff' }>) {
const lines = data.unified
.split('\n')
.filter((l) => !l.startsWith('@@') && !l.startsWith('---') && !l.startsWith('+++'));
const displayLines = lines.slice(0, detailsExpanded ? 40 : 8);
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px]">
<FileEdit className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-foreground/70">
{getShortPath(data.filename)}
</span>
<span className="text-green-600">+{data.additions}</span>
<span className="text-red-600">-{data.deletions}</span>
</div>
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
{displayLines.map((line, i) => {
const isAdd = line.startsWith('+');
const isDel = line.startsWith('-');
return (
<div
key={i}
className={cn(
'px-2 py-0.5 text-[11px] font-mono',
isAdd &&
'bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300',
isDel &&
'bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300',
!isAdd && !isDel && 'text-foreground/50'
)}
>
<span className="mr-1 opacity-50">
{isAdd ? '+' : isDel ? '-' : ' '}
</span>
{line.slice(1) || ' '}
</div>
);
})}
{lines.length > 8 && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
>
{detailsExpanded ? 'less' : `+${lines.length - 8} more...`}
</button>
)}
</div>
</div>
);
}
function renderShell(data: Extract<ToolDisplayData, { type: 'shell' }>) {
const output = data.stdout || data.stderr || '';
const lines = output.split('\n');
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
const isError = data.exitCode !== 0;
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px]">
<Terminal className="h-3 w-3 text-muted-foreground" />
<code className="font-mono text-foreground/70 truncate flex-1">
{truncate(data.command, 50)}
</code>
<span
className={cn(
'text-[10px] px-1 py-0.5 rounded',
isError
? 'bg-red-100 dark:bg-red-900/30 text-red-600'
: 'bg-green-100 dark:bg-green-900/30 text-green-600'
)}
>
{isError ? `exit ${data.exitCode}` : 'ok'}
</span>
<button
onClick={async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(data.command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
{output && (
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
{displayLines.join('\n')}
</pre>
{lines.length > 5 && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
>
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
</button>
)}
</div>
)}
</div>
);
}
function renderSearch(data: Extract<ToolDisplayData, { type: 'search' }>) {
const matches = data.matches.slice(0, detailsExpanded ? 15 : 5);
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px]">
<Search className="h-3 w-3 text-muted-foreground" />
<code className="font-mono text-foreground/70">{data.pattern}</code>
<span className="text-muted-foreground">
{data.totalMatches} matches{data.truncated && '+'}
</span>
</div>
<div className="bg-muted/30 rounded overflow-hidden border border-border/50 divide-y divide-border/30">
{matches.map((m, i) => (
<div key={i} className="px-2 py-1 text-[11px]">
<span className="text-blue-600 dark:text-blue-400 font-mono">
{getShortPath(m.file)}
</span>
{m.line > 0 && <span className="text-muted-foreground">:{m.line}</span>}
{m.content && (
<div className="text-foreground/60 font-mono truncate">
{m.content}
</div>
)}
</div>
))}
{data.matches.length > 5 && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50"
>
{detailsExpanded ? 'less' : `+${data.matches.length - 5} more...`}
</button>
)}
</div>
</div>
);
}
function renderFile(data: Extract<ToolDisplayData, { type: 'file' }>) {
const OpIcon = { read: FileText, write: FileEdit, create: FilePlus, delete: Trash2 }[
data.operation
];
const opColors = {
read: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
write: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600',
create: 'bg-green-100 dark:bg-green-900/30 text-green-600',
delete: 'bg-red-100 dark:bg-red-900/30 text-red-600',
}[data.operation];
return (
<div className="flex items-center gap-2 text-[11px]">
<OpIcon className="h-3 w-3 text-muted-foreground" />
<span className={cn('px-1 py-0.5 rounded text-[10px]', opColors)}>
{data.operation}
</span>
<span className="font-mono text-foreground/70">{getShortPath(data.path)}</span>
{data.lineCount !== undefined && (
<span className="text-muted-foreground">{data.lineCount} lines</span>
)}
</div>
);
}
// =========================================================================
// Render functions for generating preview from toolArgs (no displayData)
// =========================================================================
function renderBashResult(_command: string) {
// Extract and parse bash result from tool result
let stdout = '';
let stderr = '';
let exitCode: number | undefined;
let duration: number | undefined;
if (toolResult && typeof toolResult === 'object') {
const result = toolResult as Record<string, unknown>;
if (result.content && Array.isArray(result.content)) {
const textContent = result.content
.filter(
(p: unknown) =>
typeof p === 'object' &&
p !== null &&
(p as Record<string, unknown>).type === 'text'
)
.map((p: unknown) => (p as { text?: string }).text || '')
.join('\n');
// Try to parse as JSON bash result
try {
const parsed = JSON.parse(textContent);
if (typeof parsed === 'object' && parsed !== null) {
stdout = parsed.stdout || '';
stderr = parsed.stderr || '';
exitCode =
typeof parsed.exit_code === 'number' ? parsed.exit_code : undefined;
duration =
typeof parsed.duration === 'number' ? parsed.duration : undefined;
}
} catch {
// Not JSON, treat as plain output
stdout = textContent;
}
}
}
const output = stdout || stderr;
if (!output && exitCode === undefined) return null;
const lines = output.split('\n').filter((l) => l.trim());
const displayLines = lines.slice(0, detailsExpanded ? 25 : 5);
const isError = exitCode !== undefined && exitCode !== 0;
return (
<div className="ml-5 space-y-1">
{/* Status bar */}
<div className="flex items-center gap-2 text-[10px]">
{exitCode !== undefined && (
<span
className={cn(
'px-1.5 py-0.5 rounded font-medium',
isError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
)}
>
{isError ? `exit ${exitCode}` : 'success'}
</span>
)}
{duration !== undefined && (
<span className="text-muted-foreground">{duration}ms</span>
)}
</div>
{/* Output */}
{output && (
<div className="bg-zinc-100 dark:bg-zinc-900 rounded overflow-hidden border border-zinc-200 dark:border-zinc-800">
<pre className="p-1.5 text-[11px] text-zinc-800 dark:text-zinc-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto">
{displayLines.join('\n')}
</pre>
{lines.length > 5 && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-600 dark:text-blue-400 bg-zinc-200 dark:bg-zinc-800 border-t border-zinc-300 dark:border-zinc-700"
>
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
</button>
)}
</div>
)}
{/* Stderr if present and different from stdout */}
{stderr && stderr !== stdout && (
<div className="bg-red-50 dark:bg-red-950/30 rounded overflow-hidden border border-red-200 dark:border-red-900/30">
<div className="px-2 py-0.5 text-[10px] text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/20 border-b border-red-200 dark:border-red-900/30">
stderr
</div>
<pre className="p-1.5 text-[11px] text-red-800 dark:text-red-300 font-mono whitespace-pre-wrap max-h-24 overflow-y-auto">
{stderr}
</pre>
</div>
)}
</div>
);
}
function renderEditResult(_filePath: string, oldString: string, newString: string) {
return (
<div className="ml-5 bg-muted/30 rounded overflow-hidden border border-border/50 text-[11px] font-mono">
{oldString
.split('\n')
.slice(0, detailsExpanded ? 15 : 3)
.map((line, i) => (
<div
key={`o${i}`}
className="px-2 py-0.5 bg-red-100/50 dark:bg-red-900/20 text-red-800 dark:text-red-300"
>
<span className="text-red-500/50 mr-1">-</span>
{line || ' '}
</div>
))}
{newString
.split('\n')
.slice(0, detailsExpanded ? 15 : 3)
.map((line, i) => (
<div
key={`n${i}`}
className="px-2 py-0.5 bg-green-100/50 dark:bg-green-900/20 text-green-800 dark:text-green-300"
>
<span className="text-green-500/50 mr-1">+</span>
{line || ' '}
</div>
))}
{(oldString.split('\n').length > 3 || newString.split('\n').length > 3) && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
>
{detailsExpanded ? 'less' : 'more...'}
</button>
)}
</div>
);
}
function renderWriteResult(filePath: string, content: string) {
return (
<div className="ml-5">
<CodePreview
content={content}
filePath={filePath}
maxLines={8}
maxHeight={180}
showHeader={false}
/>
</div>
);
}
function renderReadResult(filePath: string) {
// Extract content from tool result
let content = '';
if (toolResult && typeof toolResult === 'object') {
const result = toolResult as Record<string, unknown>;
if (result.content && Array.isArray(result.content)) {
content = result.content
.filter(
(p: unknown) =>
typeof p === 'object' &&
p !== null &&
(p as Record<string, unknown>).type === 'text'
)
.map((p: unknown) => (p as { text?: string }).text || '')
.join('\n');
}
}
if (!content) return null;
return (
<div className="ml-5">
<CodePreview
content={content}
filePath={filePath}
maxLines={8}
maxHeight={180}
showHeader={false}
/>
</div>
);
}
function renderGenericResult() {
// Extract text from result
let resultText = '';
if (toolResult && typeof toolResult === 'object') {
const result = toolResult as Record<string, unknown>;
if (result.content && Array.isArray(result.content)) {
resultText = result.content
.filter(
(p: unknown) =>
typeof p === 'object' &&
p !== null &&
(p as Record<string, unknown>).type === 'text'
)
.map((p: unknown) => (p as { text?: string }).text || '')
.join('\n');
}
}
if (!resultText) return null;
const lines = resultText.split('\n');
const displayLines = lines.slice(0, detailsExpanded ? 20 : 5);
return (
<div className="bg-muted/30 rounded overflow-hidden border border-border/50">
<pre className="p-1.5 text-[11px] text-foreground/70 font-mono whitespace-pre-wrap max-h-40 overflow-y-auto">
{displayLines.join('\n')}
</pre>
{lines.length > 5 && (
<button
onClick={(e) => {
e.stopPropagation();
setDetailsExpanded(!detailsExpanded);
}}
className="w-full py-0.5 text-[10px] text-blue-500 bg-muted/50 border-t border-border/30"
>
{detailsExpanded ? 'less' : `+${lines.length - 5} more...`}
</button>
)}
</div>
);
}
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useCallback } from 'react';
import { useSubmitApproval } from './hooks/useApprovals';
import type { ApprovalRequest } from '@dexto/core';
import { ApprovalStatus } from '@dexto/core';
import { useSessionStore } from '@/lib/stores/sessionStore';
import { useApprovalStore } from '@/lib/stores/approvalStore';
// Re-export ApprovalRequest as ApprovalEvent for consumers (e.g., InlineApprovalCard, MessageList)
export type ApprovalEvent = ApprovalRequest;
interface ToolConfirmationHandlerProps {
onApprovalRequest?: (approval: ApprovalEvent | null) => void;
onApprove?: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
onDeny?: () => void;
onHandlersReady?: (handlers: ApprovalHandlers) => void;
}
export interface ApprovalHandlers {
onApprove: (formData?: Record<string, unknown>, rememberChoice?: boolean) => void;
onDeny: () => void;
}
/**
* WebUI component for handling approval requests
* Uses approvalStore for state management (no DOM events)
* Sends responses back through API via useSubmitApproval
*/
export function ToolConfirmationHandler({
onApprovalRequest,
onApprove: externalOnApprove,
onDeny: externalOnDeny,
onHandlersReady,
}: ToolConfirmationHandlerProps) {
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const pendingApproval = useApprovalStore((s) => s.pendingApproval);
const clearApproval = useApprovalStore((s) => s.clearApproval);
const { mutateAsync: submitApproval } = useSubmitApproval();
// Filter approvals by current session
const currentApproval =
pendingApproval &&
(!pendingApproval.sessionId ||
!currentSessionId ||
pendingApproval.sessionId === currentSessionId)
? pendingApproval
: null;
// Notify parent component when approval state changes
useEffect(() => {
onApprovalRequest?.(currentApproval);
}, [currentApproval, onApprovalRequest]);
// Send confirmation response via API
const sendResponse = useCallback(
async (approved: boolean, formData?: Record<string, unknown>, rememberChoice?: boolean) => {
if (!currentApproval) return;
const { approvalId } = currentApproval;
console.debug(
`[WebUI] Sending approval response for ${approvalId}: ${approved ? 'approved' : 'denied'}`
);
// Use approval's sessionId as authoritative source for cache invalidation
const sessionId = currentApproval.sessionId || currentSessionId;
if (!sessionId) {
console.error('[WebUI] Cannot submit approval without sessionId');
return;
}
try {
await submitApproval({
approvalId,
sessionId,
status: approved ? ApprovalStatus.APPROVED : ApprovalStatus.DENIED,
...(approved && formData ? { formData } : {}),
...(approved && rememberChoice !== undefined ? { rememberChoice } : {}),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[WebUI] Failed to send approval response: ${message}`);
return;
}
// Clear current approval (processes queue automatically)
clearApproval();
},
[currentApproval, currentSessionId, submitApproval, clearApproval]
);
const handleApprove = useCallback(
(formData?: Record<string, unknown>, rememberChoice?: boolean) => {
sendResponse(true, formData, rememberChoice);
externalOnApprove?.(formData, rememberChoice);
},
[sendResponse, externalOnApprove]
);
const handleDeny = useCallback(() => {
sendResponse(false);
externalOnDeny?.();
}, [sendResponse, externalOnDeny]);
// Expose handlers to parent via callback
useEffect(() => {
if (onHandlersReady) {
onHandlersReady({
onApprove: handleApprove,
onDeny: handleDeny,
});
}
}, [handleApprove, handleDeny, onHandlersReady]);
// Don't render anything - the approval will be rendered in MessageList
return null;
}

View File

@@ -0,0 +1,71 @@
/**
* Approval Context
*
* Provides approval handling functionality via React Context.
* Wraps the approvalStore to provide a clean API for components.
*/
import { createContext, useContext, useCallback, type ReactNode } from 'react';
import { useApprovalStore } from '@/lib/stores/approvalStore';
import type { ApprovalRequest } from '@dexto/core';
// =============================================================================
// Types
// =============================================================================
interface ApprovalContextType {
/**
* Handle an incoming approval request (add to store)
*/
handleApprovalRequest: (request: ApprovalRequest) => void;
}
// =============================================================================
// Context
// =============================================================================
const ApprovalContext = createContext<ApprovalContextType | null>(null);
// =============================================================================
// Provider
// =============================================================================
interface ApprovalProviderProps {
children: ReactNode;
}
export function ApprovalProvider({ children }: ApprovalProviderProps) {
const addApproval = useApprovalStore((s) => s.addApproval);
const handleApprovalRequest = useCallback(
(request: ApprovalRequest) => {
addApproval(request);
},
[addApproval]
);
return (
<ApprovalContext.Provider value={{ handleApprovalRequest }}>
{children}
</ApprovalContext.Provider>
);
}
// =============================================================================
// Hook
// =============================================================================
/**
* Hook to access approval handling functions
*
* @throws Error if used outside ApprovalProvider
*/
export function useApproval(): ApprovalContextType {
const context = useContext(ApprovalContext);
if (!context) {
throw new Error('useApproval must be used within an ApprovalProvider');
}
return context;
}

View File

@@ -0,0 +1,687 @@
import React, {
createContext,
useContext,
ReactNode,
useEffect,
useState,
useCallback,
useRef,
} from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useChat, Message, UIUserMessage, UIAssistantMessage, UIToolMessage } from './useChat';
import { useApproval } from './ApprovalContext';
import { usePendingApprovals } from './useApprovals';
import type { FilePart, ImagePart, TextPart, UIResourcePart } from '../../types';
import type { SanitizedToolResult, ApprovalRequest } from '@dexto/core';
import { getResourceKind } from '@dexto/core';
import { useAnalytics } from '@/lib/analytics/index.js';
import { queryKeys } from '@/lib/queryKeys.js';
import { client } from '@/lib/client.js';
import { useMutation } from '@tanstack/react-query';
import {
useAgentStore,
useSessionStore,
useChatStore,
useCurrentSessionId,
useIsWelcomeState,
useSessionMessages,
} from '@/lib/stores/index.js';
// Helper to get history endpoint type (workaround for string literal path)
type HistoryEndpoint = (typeof client.api.sessions)[':sessionId']['history'];
// Derive history message type from Hono client response (server schema is source of truth)
type HistoryResponse = Awaited<ReturnType<HistoryEndpoint['$get']>>;
type HistoryData = Awaited<ReturnType<Extract<HistoryResponse, { ok: true }>['json']>>;
type HistoryMessage = HistoryData['history'][number];
// Derive toolCall type from HistoryMessage
type ToolCall = NonNullable<HistoryMessage['toolCalls']>[number];
interface ChatContextType {
messages: Message[];
sendMessage: (
content: string,
imageData?: { image: string; mimeType: string },
fileData?: { data: string; mimeType: string; filename?: string }
) => void;
reset: () => void;
switchSession: (sessionId: string) => void;
loadSessionHistory: (sessionId: string) => Promise<void>;
returnToWelcome: () => void;
cancel: (sessionId?: string) => void;
}
// Helper function to fetch and convert session history to UI messages
async function fetchSessionHistory(
sessionId: string
): Promise<{ messages: Message[]; isBusy: boolean }> {
const response = await client.api.sessions[':sessionId'].history.$get({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to fetch session history');
}
const data = await response.json();
const history = data.history || [];
return {
messages: convertHistoryToMessages(history, sessionId),
isBusy: data.isBusy ?? false,
};
}
// Helper function to convert session history API response to UI messages
function convertHistoryToMessages(history: HistoryMessage[], sessionId: string): Message[] {
const uiMessages: Message[] = [];
const pendingToolCalls = new Map<string, number>();
for (let index = 0; index < history.length; index++) {
const msg = history[index];
const createdAt = msg.timestamp ?? Date.now() - (history.length - index) * 1000;
const baseId = `session-${sessionId}-${index}`;
// Skip system messages - they're not shown in UI
if (msg.role === 'system') {
continue;
}
const deriveResources = (
content: Array<TextPart | ImagePart | FilePart | UIResourcePart>
): SanitizedToolResult['resources'] => {
const resources: NonNullable<SanitizedToolResult['resources']> = [];
for (const part of content) {
if (
part.type === 'image' &&
typeof part.image === 'string' &&
part.image.startsWith('@blob:')
) {
const uri = part.image.substring(1);
resources.push({
uri,
kind: 'image',
mimeType: part.mimeType ?? 'image/jpeg',
});
}
if (
part.type === 'file' &&
typeof part.data === 'string' &&
part.data.startsWith('@blob:')
) {
const uri = part.data.substring(1);
const mimeType = part.mimeType ?? 'application/octet-stream';
const kind = getResourceKind(mimeType);
resources.push({
uri,
kind,
mimeType,
...(part.filename ? { filename: part.filename } : {}),
});
}
}
return resources.length > 0 ? resources : undefined;
};
if (msg.role === 'assistant') {
// Create assistant message
if (msg.content) {
// Extract text content from string or ContentPart array
let textContent: string | null = null;
if (typeof msg.content === 'string') {
textContent = msg.content;
} else if (Array.isArray(msg.content)) {
// Extract text from ContentPart array
const textParts = msg.content
.filter((part): part is TextPart => part.type === 'text')
.map((part) => part.text);
textContent = textParts.length > 0 ? textParts.join('\n') : null;
}
const assistantMessage: UIAssistantMessage = {
id: baseId,
role: 'assistant',
content: textContent,
createdAt,
sessionId,
tokenUsage: msg.tokenUsage,
reasoning: msg.reasoning,
model: msg.model,
provider: msg.provider,
};
uiMessages.push(assistantMessage);
}
// Create tool messages for tool calls
if (msg.toolCalls && msg.toolCalls.length > 0) {
msg.toolCalls.forEach((toolCall: ToolCall, toolIndex: number) => {
let toolArgs: Record<string, unknown> = {};
if (toolCall?.function) {
try {
toolArgs = JSON.parse(toolCall.function.arguments || '{}');
} catch (e) {
console.warn(
`Failed to parse toolCall arguments for ${toolCall.function?.name || 'unknown'}: ${e}`
);
toolArgs = {};
}
}
const toolName = toolCall.function?.name || 'unknown';
const toolMessage: UIToolMessage = {
id: `${baseId}-tool-${toolIndex}`,
role: 'tool',
content: null,
createdAt: createdAt + toolIndex,
sessionId,
toolName,
toolArgs,
toolCallId: toolCall.id,
};
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
pendingToolCalls.set(toolCall.id, uiMessages.length);
}
uiMessages.push(toolMessage);
});
}
continue;
}
if (msg.role === 'tool') {
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
const toolName = typeof msg.name === 'string' ? msg.name : 'unknown';
const normalizedContent: Array<TextPart | ImagePart | FilePart> = Array.isArray(
msg.content
)
? (msg.content as Array<TextPart | ImagePart | FilePart>)
: typeof msg.content === 'string'
? [{ type: 'text', text: msg.content }]
: [];
const inferredResources = deriveResources(normalizedContent);
// Extract success status from stored message (defaults to true for backwards compatibility)
const success =
'success' in msg && typeof msg.success === 'boolean' ? msg.success : true;
const sanitizedFromHistory: SanitizedToolResult = {
content: normalizedContent,
...(inferredResources ? { resources: inferredResources } : {}),
meta: {
toolName,
toolCallId: toolCallId ?? `tool-${index}`,
success,
},
};
// Extract approval metadata if present (type-safe with optional chaining)
const requireApproval: boolean | undefined =
'requireApproval' in msg && typeof msg.requireApproval === 'boolean'
? msg.requireApproval
: undefined;
const approvalStatus: 'pending' | 'approved' | 'rejected' | undefined =
'approvalStatus' in msg &&
(msg.approvalStatus === 'pending' ||
msg.approvalStatus === 'approved' ||
msg.approvalStatus === 'rejected')
? msg.approvalStatus
: undefined;
if (toolCallId && pendingToolCalls.has(toolCallId)) {
// Update existing tool message with result
const messageIndex = pendingToolCalls.get(toolCallId)!;
const existingMessage = uiMessages[messageIndex] as UIToolMessage;
uiMessages[messageIndex] = {
...existingMessage,
toolResult: sanitizedFromHistory,
toolResultMeta: sanitizedFromHistory.meta,
toolResultSuccess: sanitizedFromHistory.meta?.success,
...(requireApproval !== undefined && { requireApproval }),
...(approvalStatus !== undefined && { approvalStatus }),
};
} else {
// Create new tool message with result
const toolMessage: UIToolMessage = {
id: baseId,
role: 'tool',
content: null,
createdAt,
sessionId,
toolName,
toolCallId,
toolResult: sanitizedFromHistory,
toolResultMeta: sanitizedFromHistory.meta,
toolResultSuccess: sanitizedFromHistory.meta?.success,
...(requireApproval !== undefined && { requireApproval }),
...(approvalStatus !== undefined && { approvalStatus }),
};
uiMessages.push(toolMessage);
}
continue;
}
// User message (only remaining case after system/assistant/tool handled)
if (msg.role === 'user') {
const userMessage: UIUserMessage = {
id: baseId,
role: 'user',
content: msg.content,
createdAt,
sessionId,
};
uiMessages.push(userMessage);
}
}
// Mark any tool calls that never received results as failed (incomplete)
// This happens when a run was interrupted or crashed before tool completion
for (const [_callId, messageIndex] of pendingToolCalls) {
const msg = uiMessages[messageIndex];
if (msg && msg.role === 'tool' && msg.toolResult === undefined) {
uiMessages[messageIndex] = {
...msg,
toolResultSuccess: false, // Mark as failed so UI doesn't show "running"
};
}
}
return uiMessages;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export function ChatProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const analytics = useAnalytics();
const queryClient = useQueryClient();
// Get state from stores using centralized selectors
const currentSessionId = useCurrentSessionId();
const isWelcomeState = useIsWelcomeState();
// Local state for UI flow control only (not shared/persisted state)
const [isSwitchingSession, setIsSwitchingSession] = useState(false); // Guard against rapid session switches
const [isCreatingSession, setIsCreatingSession] = useState(false); // Guard against double auto-creation
const lastSwitchedSessionRef = useRef<string | null>(null); // Track last switched session to prevent duplicate switches
const newSessionWithMessageRef = useRef<string | null>(null); // Track new sessions that already have first message sent
const currentSessionIdRef = useRef<string | null>(null); // Synchronous session ID (updates before React state to prevent race conditions)
// Session abort controllers for cancellation
const sessionAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
// useChat hook manages abort controllers internally
const {
sendMessage: originalSendMessage,
reset: originalReset,
cancel,
} = useChat(currentSessionIdRef, sessionAbortControllersRef);
// Restore pending approvals when session changes (e.g., after page refresh)
const { handleApprovalRequest } = useApproval();
const { data: pendingApprovalsData } = usePendingApprovals(currentSessionId);
const restoredApprovalsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!pendingApprovalsData?.approvals || pendingApprovalsData.approvals.length === 0) {
return;
}
// Restore any pending approvals that haven't been restored yet
for (const approval of pendingApprovalsData.approvals) {
// Skip if we've already restored this approval
if (restoredApprovalsRef.current.has(approval.approvalId)) {
continue;
}
// Mark as restored before calling handler to prevent duplicates
restoredApprovalsRef.current.add(approval.approvalId);
// Convert API response format to ApprovalRequest format
// TODO: The API returns a simplified format without full metadata because
// ApprovalCoordinator only tracks approval IDs, not the full request data.
// To fix properly: store full ApprovalRequest in ApprovalCoordinator when
// requests are created, then return that data from GET /api/approvals.
handleApprovalRequest({
approvalId: approval.approvalId,
type: approval.type,
sessionId: approval.sessionId,
timeout: approval.timeout,
timestamp: new Date(approval.timestamp),
metadata: approval.metadata,
} as ApprovalRequest);
}
}, [pendingApprovalsData, handleApprovalRequest]);
// Clear restored approvals tracking when session changes
useEffect(() => {
restoredApprovalsRef.current.clear();
}, [currentSessionId]);
// Messages from centralized selector (stable reference, handles null session)
const messages = useSessionMessages(currentSessionId);
// Mutation for generating session title
const { mutate: generateTitle } = useMutation({
mutationFn: async (sessionId: string) => {
const response = await client.api.sessions[':sessionId']['generate-title'].$post({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to generate title');
}
const data = await response.json();
return data.title;
},
onSuccess: (_title, sessionId) => {
// Invalidate sessions query to show the new title
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
// Also invalidate the specific session query if needed
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(sessionId) });
},
});
// Auto-create session on first message with random UUID
const createAutoSession = useCallback(async (): Promise<string> => {
const response = await client.api.sessions.$post({
json: {}, // Let server generate random UUID
});
if (!response.ok) {
throw new Error('Failed to create session');
}
const data = await response.json();
if (!data.session?.id) {
throw new Error('Session ID not found in server response');
}
const sessionId = data.session.id;
// Track session creation
analytics.trackSessionCreated({
sessionId,
trigger: 'first_message',
});
return sessionId;
}, [analytics]);
// Enhanced sendMessage with auto-session creation
const sendMessage = useCallback(
async (
content: string,
imageData?: { image: string; mimeType: string },
fileData?: { data: string; mimeType: string; filename?: string }
) => {
let sessionId = currentSessionId;
let isNewSession = false;
// Auto-create session on first message and wait for it to complete
if (!sessionId && isWelcomeState) {
if (isCreatingSession) return; // Another send in-flight; drop duplicate request
try {
setIsCreatingSession(true);
sessionId = await createAutoSession();
isNewSession = true;
// Mark this session as a new session before navigation
// This allows switchSession to run but skip history load
newSessionWithMessageRef.current = sessionId;
// Update ref BEFORE streaming to prevent race conditions
currentSessionIdRef.current = sessionId;
// Send message BEFORE navigating
originalSendMessage(content, imageData, fileData, sessionId);
// Navigate - this will trigger switchSession via ChatApp useEffect
navigate({ to: `/chat/${sessionId}`, replace: true });
// Generate title for newly created session after first message
// Use setTimeout to delay title generation until message is complete
setTimeout(() => {
if (sessionId) generateTitle(sessionId);
}, 0);
// Note: currentLLM will automatically refetch when currentSessionId changes
} catch (error) {
console.error('Failed to create session:', error);
return; // Don't send message if session creation fails
} finally {
setIsCreatingSession(false);
}
}
// Only send if we're using an existing session (not a newly created one)
if (sessionId && !isNewSession) {
originalSendMessage(content, imageData, fileData, sessionId);
}
// Track message sent
if (sessionId) {
analytics.trackMessageSent({
sessionId,
provider: 'unknown', // Provider/model tracking moved to component level
model: 'unknown',
hasImage: !!imageData,
hasFile: !!fileData,
messageLength: content.length,
});
} else {
console.error('No session available for sending message');
}
},
[
originalSendMessage,
currentSessionId,
isWelcomeState,
isCreatingSession,
createAutoSession,
navigate,
analytics,
generateTitle,
]
);
// Enhanced reset with session support
const reset = useCallback(() => {
if (currentSessionId) {
// Track conversation reset
const messageCount = messages.filter((m) => m.sessionId === currentSessionId).length;
analytics.trackSessionReset({
sessionId: currentSessionId,
messageCount,
});
originalReset(currentSessionId);
}
}, [originalReset, currentSessionId, analytics, messages]);
// Load session history when switching sessions
const { data: sessionHistoryData } = useQuery({
queryKey: queryKeys.sessions.history(currentSessionId || ''),
queryFn: async () => {
if (!currentSessionId) {
return { messages: [], isBusy: false };
}
try {
return await fetchSessionHistory(currentSessionId);
} catch {
// Return empty result for 404 or other errors
return { messages: [], isBusy: false };
}
},
enabled: false, // Manual refetch only
retry: false,
});
// Sync session history data to messages when it changes
useEffect(() => {
if (sessionHistoryData && currentSessionId) {
const currentMessages = useChatStore.getState().getMessages(currentSessionId);
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === currentSessionId);
if (!hasSessionMsgs) {
useChatStore
.getState()
.setMessages(currentSessionId, sessionHistoryData.messages as any);
}
// Cancel any active run on page refresh (we can't reconnect to the stream)
if (sessionHistoryData.isBusy) {
// Reset agent state since we're cancelling - we won't receive run:complete event
useAgentStore.getState().setIdle();
client.api.sessions[':sessionId'].cancel
.$post({
param: { sessionId: currentSessionId },
json: { clearQueue: true },
})
.catch((e) => console.warn('Failed to cancel busy session:', e));
}
}
}, [sessionHistoryData, currentSessionId]);
const loadSessionHistory = useCallback(
async (sessionId: string) => {
try {
const result = await queryClient.fetchQuery({
queryKey: queryKeys.sessions.history(sessionId),
queryFn: async () => {
try {
return await fetchSessionHistory(sessionId);
} catch {
// Return empty result for 404 or other errors
return { messages: [], isBusy: false };
}
},
retry: false,
});
const currentMessages = useChatStore.getState().getMessages(sessionId);
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === sessionId);
if (!hasSessionMsgs) {
// Populate chatStore with history (cast to compatible type)
useChatStore.getState().initFromHistory(sessionId, result.messages as any);
}
// Cancel any active run on page refresh (we can't reconnect to the stream)
// This ensures clean state - user can see history and send new messages
// TODO: Implement stream reconnection instead of cancelling
// - Add GET /sessions/{sessionId}/events SSE endpoint for listen-only mode
// - Connect to event stream when isBusy=true to resume receiving updates
if (result.isBusy) {
// Reset agent state since we're cancelling - we won't receive run:complete event
useAgentStore.getState().setIdle();
try {
await client.api.sessions[':sessionId'].cancel.$post({
param: { sessionId },
json: { clearQueue: true }, // Hard cancel - clear queue too
});
} catch (e) {
console.warn('Failed to cancel busy session on load:', e);
}
}
} catch (error) {
console.error('Error loading session history:', error);
useChatStore.getState().clearMessages(sessionId);
}
},
[queryClient]
);
// Switch to a different session and load it on the backend
const switchSession = useCallback(
async (sessionId: string) => {
// Guard against switching to same session or rapid successive switches
// Use ref for immediate check (state updates are async)
if (
sessionId === currentSessionId ||
sessionId === lastSwitchedSessionRef.current ||
isSwitchingSession
) {
return;
}
setIsSwitchingSession(true);
try {
// Track session switch (defensive - analytics failures shouldn't block switching)
try {
analytics.trackSessionSwitched({
fromSessionId: currentSessionId,
toSessionId: sessionId,
});
} catch (analyticsError) {
console.error('Failed to track session switch:', analyticsError);
}
// Skip history load for newly created sessions with first message already sent
// This prevents replacing message IDs and breaking error anchoring
// TODO: Long-term fix - backend should generate and persist message IDs
// so history reload doesn't cause ID mismatches
const skipHistoryLoad = newSessionWithMessageRef.current === sessionId;
if (skipHistoryLoad) {
// Clear the ref after using it once
newSessionWithMessageRef.current = null;
}
// Update ref BEFORE store to prevent race conditions with streaming
currentSessionIdRef.current = sessionId;
// Update store (single source of truth)
useSessionStore.getState().setCurrentSession(sessionId);
// Mark this session as being switched to after state update succeeds
lastSwitchedSessionRef.current = sessionId;
if (!skipHistoryLoad) {
await loadSessionHistory(sessionId);
}
// Note: currentLLM will automatically refetch when currentSessionId changes via useQuery
} catch (error) {
console.error('Error switching session:', error);
throw error; // Re-throw so UI can handle the error
} finally {
// Always reset the switching flag, even if error occurs
setIsSwitchingSession(false);
}
},
[currentSessionId, isSwitchingSession, loadSessionHistory, analytics]
);
// Return to welcome state (no active session)
const returnToWelcome = useCallback(() => {
currentSessionIdRef.current = null;
lastSwitchedSessionRef.current = null; // Clear to allow switching to same session again
// Update store (single source of truth)
useSessionStore.getState().returnToWelcome();
}, []);
return (
<ChatContext.Provider
value={{
messages,
sendMessage,
reset,
switchSession,
loadSessionHistory,
returnToWelcome,
cancel,
}}
>
{children}
</ChatContext.Provider>
);
}
export function useChatContext(): ChatContextType {
const context = useContext(ChatContext);
if (!context) {
throw new Error('useChatContext must be used within a ChatProvider');
}
return context;
}

View File

@@ -0,0 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
// Fetch agent configuration
export function useAgentConfig(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.agent.config,
queryFn: async () => {
const response = await client.api.agent.config.$get();
return await response.json();
},
enabled,
staleTime: 30000, // 30 seconds
});
}
// Validate agent configuration
export function useValidateAgent() {
return useMutation({
mutationFn: async ({ yaml }: { yaml: string }) => {
const response = await client.api.agent.validate.$post({
json: { yaml },
});
return await response.json();
},
});
}
// Export types inferred from hook return values
export type ValidationError = NonNullable<
ReturnType<typeof useValidateAgent>['data']
>['errors'][number];
export type ValidationWarning = NonNullable<
ReturnType<typeof useValidateAgent>['data']
>['warnings'][number];
// Save agent configuration
export function useSaveAgentConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ yaml }: { yaml: string }) => {
const response = await client.api.agent.config.$post({
json: { yaml },
});
return await response.json();
},
onSuccess: () => {
// Invalidate agent config to refresh after save
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config });
},
});
}

View File

@@ -0,0 +1,115 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useAgents() {
return useQuery({
queryKey: queryKeys.agents.all,
queryFn: async () => {
const response = await client.api.agents.$get();
if (!response.ok) {
throw new Error(`Failed to fetch agents: ${response.status}`);
}
return await response.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes - agent list rarely changes
});
}
export function useAgentPath() {
return useQuery({
queryKey: queryKeys.agents.path,
queryFn: async () => {
const response = await client.api.agent.path.$get();
if (!response.ok) {
throw new Error(`Failed to fetch agent path: ${response.status}`);
}
return await response.json();
},
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes - current agent path only changes on explicit switch
});
}
export function useSwitchAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.switch.$post>[0]['json']
) => {
const response = await client.api.agents.switch.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to switch agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
},
});
}
export function useInstallAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.install.$post>[0]['json']
) => {
const response = await client.api.agents.install.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to install agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
},
});
}
export function useUninstallAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.uninstall.$post>[0]['json']
) => {
const response = await client.api.agents.uninstall.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to uninstall agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
},
});
}
export function useCreateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.custom.create.$post>[0]['json']
) => {
const response = await client.api.agents.custom.create.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
},
});
}
// Export inferred types for components to use
export type CreateAgentPayload = Parameters<
typeof client.api.agents.custom.create.$post
>[0]['json'];

View File

@@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
type ApprovalPayload = Parameters<(typeof client.api.approvals)[':approvalId']['$post']>[0]['json'];
export function useSubmitApproval() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: { approvalId: string; sessionId: string } & ApprovalPayload
) => {
const { approvalId, sessionId: _sessionId, ...body } = payload;
const response = await client.api.approvals[':approvalId'].$post({
param: { approvalId },
json: body,
header: {},
});
if (!response.ok) {
throw new Error(`Failed to submit approval: ${response.status}`);
}
return await response.json();
},
onSuccess: (_, variables) => {
// Invalidate pending approvals cache when an approval is submitted
// Query is keyed by sessionId, not approvalId
queryClient.invalidateQueries({
queryKey: queryKeys.approvals.pending(variables.sessionId),
});
},
});
}
/**
* Hook to fetch pending approvals for a session.
* Use this to restore approval UI state after page refresh.
*
* @param sessionId - The session ID to fetch pending approvals for
* @param options.enabled - Whether to enable the query (default: true if sessionId provided)
*/
export function usePendingApprovals(sessionId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.approvals.pending(sessionId || ''),
queryFn: async () => {
if (!sessionId) return { approvals: [] };
const response = await client.api.approvals.$get({
query: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to fetch pending approvals');
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!sessionId,
});
}
// Export inferred types for consumers
export type PendingApproval = NonNullable<
ReturnType<typeof usePendingApprovals>['data']
>['approvals'][number];

View File

@@ -0,0 +1,405 @@
import React, { useRef, useEffect, useCallback } from 'react';
import type { SanitizedToolResult } from '@dexto/core';
import { useQueryClient } from '@tanstack/react-query';
import { useAnalytics } from '@/lib/analytics/index.js';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
import { createMessageStream } from '@dexto/client-sdk';
import type { MessageStreamEvent } from '@dexto/client-sdk';
import { eventBus } from '@/lib/events/EventBus.js';
import { useChatStore } from '@/lib/stores/chatStore.js';
import type { Session } from './useSessions.js';
// Content part types - import from centralized types.ts
import type { FileData } from '../../types.js';
// Tool result types
export interface ToolResultError {
error: string | Record<string, unknown>;
}
export type ToolResultContent = SanitizedToolResult;
export type ToolResult = ToolResultError | SanitizedToolResult | string | Record<string, unknown>;
// Type guards for tool results
export function isToolResultError(result: unknown): result is ToolResultError {
return typeof result === 'object' && result !== null && 'error' in result;
}
export function isToolResultContent(result: unknown): result is ToolResultContent {
return (
typeof result === 'object' &&
result !== null &&
'content' in result &&
Array.isArray((result as ToolResultContent).content)
);
}
// =============================================================================
// Re-export Message types from chatStore (single source of truth)
// =============================================================================
// Import from chatStore
import type { Message } from '@/lib/stores/chatStore.js';
// Re-export for API compatibility - components can import these from either place
export type { Message, ErrorMessage } from '@/lib/stores/chatStore.js';
// Legacy type aliases for code that uses the discriminated union pattern
// These are intersection types that narrow the Message type by role
export type UIUserMessage = Message & { role: 'user' };
export type UIAssistantMessage = Message & { role: 'assistant' };
export type UIToolMessage = Message & { role: 'tool' };
// =============================================================================
// Message Type Guards
// =============================================================================
/** Type guard for user messages */
export function isUserMessage(msg: Message): msg is UIUserMessage {
return msg.role === 'user';
}
/** Type guard for assistant messages */
export function isAssistantMessage(msg: Message): msg is UIAssistantMessage {
return msg.role === 'assistant';
}
/** Type guard for tool messages */
export function isToolMessage(msg: Message): msg is UIToolMessage {
return msg.role === 'tool';
}
export type StreamStatus = 'idle' | 'connecting' | 'open' | 'closed';
const generateUniqueId = () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
export function useChat(
activeSessionIdRef: React.MutableRefObject<string | null>,
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>
) {
const analytics = useAnalytics();
const analyticsRef = useRef(analytics);
const queryClient = useQueryClient();
// Helper to update sessions cache (replaces DOM events)
const updateSessionActivity = useCallback(
(sessionId: string, incrementMessageCount: boolean = true) => {
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) => {
const exists = old.some((s) => s.id === sessionId);
if (exists) {
return old.map((session) =>
session.id === sessionId
? {
...session,
...(incrementMessageCount && {
messageCount: session.messageCount + 1,
}),
lastActivity: Date.now(),
}
: session
);
} else {
// Create new session entry
const newSession: Session = {
id: sessionId,
createdAt: Date.now(),
lastActivity: Date.now(),
messageCount: 1,
title: null,
};
return [newSession, ...old];
}
});
},
[queryClient]
);
const updateSessionTitle = useCallback(
(sessionId: string, title: string) => {
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) =>
old.map((session) => (session.id === sessionId ? { ...session, title } : session))
);
},
[queryClient]
);
// Track message IDs for error anchoring
const lastUserMessageIdRef = useRef<string | null>(null);
const lastMessageIdRef = useRef<string | null>(null);
// Map callId to message index for O(1) tool result pairing
const pendingToolCallsRef = useRef<Map<string, number>>(new Map());
// Keep analytics ref updated
useEffect(() => {
analyticsRef.current = analytics;
}, [analytics]);
// Abort controller management (moved from ChatContext)
const getAbortController = useCallback(
(sessionId: string): AbortController => {
const existing = abortControllersRef.current.get(sessionId);
if (existing) {
return existing;
}
const controller = new AbortController();
abortControllersRef.current.set(sessionId, controller);
return controller;
},
[abortControllersRef]
);
const abortSession = useCallback(
(sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
if (controller) {
controller.abort();
abortControllersRef.current.delete(sessionId);
}
},
[abortControllersRef]
);
const isForActiveSession = useCallback(
(sessionId?: string): boolean => {
if (!sessionId) return false;
const current = activeSessionIdRef.current;
return !!current && sessionId === current;
},
[activeSessionIdRef]
);
const processEvent = useCallback(
(event: MessageStreamEvent) => {
// All streaming events must have sessionId
if (!event.sessionId) {
console.error(`Event missing sessionId: ${JSON.stringify(event)}`);
return;
}
// Check session match
if (!isForActiveSession(event.sessionId)) {
return;
}
// Dispatch to EventBus - handlers.ts will update chatStore
// NOTE: All store updates (messages, streaming, processing) are handled by handlers.ts
// This function only handles React-specific side effects not in handlers.ts:
// - TanStack Query cache updates
// - Analytics tracking
// - Ref updates for error anchoring
eventBus.dispatch(event);
// Handle React-specific side effects not in handlers.ts
// IMPORTANT: Do NOT update chatStore here - that's handled by handlers.ts via EventBus
switch (event.name) {
case 'llm:response': {
// Update sessions cache (response received)
updateSessionActivity(event.sessionId);
break;
}
case 'llm:tool-call': {
const { toolName, sessionId } = event;
// Track tool called analytics
if (toolName) {
analyticsRef.current.trackToolCalled({
toolName,
sessionId,
});
}
break;
}
case 'llm:tool-result': {
const { toolName, success } = event;
// Track tool result analytics
if (toolName) {
analyticsRef.current.trackToolResult({
toolName,
success: success !== false,
sessionId: event.sessionId,
});
}
break;
}
case 'session:title-updated': {
// Update TanStack Query cache
updateSessionTitle(event.sessionId, event.title);
break;
}
case 'message:dequeued': {
// Update sessions cache
updateSessionActivity(event.sessionId);
// Invalidate queue cache so UI removes the message
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(event.sessionId),
});
break;
}
}
},
[isForActiveSession, analyticsRef, updateSessionActivity, updateSessionTitle, queryClient]
);
const sendMessage = useCallback(
async (
content: string,
imageData?: { image: string; mimeType: string },
fileData?: FileData,
sessionId?: string
) => {
if (!sessionId) {
console.error('Session ID required for sending message');
return;
}
// Abort previous request if any
abortSession(sessionId);
const abortController = getAbortController(sessionId) || new AbortController();
useChatStore.getState().setProcessing(sessionId, true);
// Add user message to state
const userId = generateUniqueId();
lastUserMessageIdRef.current = userId;
lastMessageIdRef.current = userId; // Track for error anchoring
useChatStore.getState().addMessage(sessionId, {
id: userId,
role: 'user',
content,
createdAt: Date.now(),
sessionId,
imageData,
fileData,
});
// Update sessions cache (user message sent)
updateSessionActivity(sessionId);
try {
// Build content parts array from text, image, and file data
// New API uses unified ContentInput = string | ContentPart[]
const contentParts: Array<
| { type: 'text'; text: string }
| { type: 'image'; image: string; mimeType?: string }
| { type: 'file'; data: string; mimeType: string; filename?: string }
> = [];
if (content) {
contentParts.push({ type: 'text', text: content });
}
if (imageData) {
contentParts.push({
type: 'image',
image: imageData.image,
mimeType: imageData.mimeType,
});
}
if (fileData) {
contentParts.push({
type: 'file',
data: fileData.data,
mimeType: fileData.mimeType,
filename: fileData.filename,
});
}
// Always use SSE for all events (tool calls, approvals, responses)
// The 'stream' flag only controls whether chunks update UI incrementally
const responsePromise = client.api['message-stream'].$post({
json: {
content:
contentParts.length === 1 && contentParts[0]?.type === 'text'
? content // Simple text-only case: send as string
: contentParts, // Multimodal: send as array
sessionId,
},
});
const iterator = createMessageStream(responsePromise, {
signal: abortController.signal,
});
for await (const event of iterator) {
processEvent(event);
}
} catch (error: unknown) {
// Handle abort gracefully
if (error instanceof Error && error.name === 'AbortError') {
console.log('Stream aborted by user');
return;
}
console.error(
`Stream error: ${error instanceof Error ? error.message : String(error)}`
);
useChatStore.getState().setProcessing(sessionId, false);
const message = error instanceof Error ? error.message : 'Failed to send message';
useChatStore.getState().setError(sessionId, {
id: generateUniqueId(),
message,
timestamp: Date.now(),
context: 'stream',
recoverable: true,
sessionId,
anchorMessageId: lastMessageIdRef.current || undefined,
});
}
},
[processEvent, abortSession, getAbortController, updateSessionActivity]
);
const reset = useCallback(async (sessionId?: string) => {
if (!sessionId) return;
try {
await client.api.reset.$post({
json: { sessionId },
});
} catch (e) {
console.error(`Failed to reset session: ${e instanceof Error ? e.message : String(e)}`);
}
// Note: Messages are now in chatStore, not local state
useChatStore.getState().setError(sessionId, null);
lastUserMessageIdRef.current = null;
lastMessageIdRef.current = null;
pendingToolCallsRef.current.clear();
useChatStore.getState().setProcessing(sessionId, false);
}, []);
const cancel = useCallback(async (sessionId?: string, clearQueue: boolean = false) => {
if (!sessionId) return;
// Tell server to cancel the LLM stream
// Soft cancel (default): Only cancel current response, queued messages continue
// Hard cancel (clearQueue=true): Cancel current response AND clear all queued messages
try {
await client.api.sessions[':sessionId'].cancel.$post({
param: { sessionId },
json: { clearQueue },
});
} catch (err) {
// Server cancel is best-effort - log but don't throw
console.warn('Failed to cancel server-side:', err);
}
// UI state will be updated when server sends run:complete event
pendingToolCallsRef.current.clear();
}, []);
return {
sendMessage,
reset,
cancel,
};
}

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
/**
* Hook to fetch the current LLM configuration for a session
* Centralized access point for currentLLM data
*/
export function useCurrentLLM(sessionId: string | null, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.llm.current(sessionId),
queryFn: async () => {
const response = await client.api.llm.current.$get({
query: sessionId ? { sessionId } : {},
});
if (!response.ok) {
throw new Error('Failed to fetch current LLM config');
}
const data = await response.json();
const cfg = data.config || data;
return {
provider: cfg.provider,
model: cfg.model,
displayName: cfg.displayName,
baseURL: cfg.baseURL,
viaDexto: data.routing?.viaDexto ?? false,
};
},
// Always enabled - API returns default config when no sessionId
// This ensures the model name shows on welcome screen
enabled,
retry: false, // Don't retry on error - UI can still operate
});
}
// Export type for components to use
export type CurrentLLM = NonNullable<ReturnType<typeof useCurrentLLM>['data']>;

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch Dexto authentication status.
* Returns whether dexto auth is enabled, user is authenticated, and can use dexto provider.
*/
export function useDextoAuth(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.dextoAuth.status,
queryFn: async () => {
const res = await client.api['dexto-auth'].status.$get();
if (!res.ok) throw new Error('Failed to fetch dexto auth status');
return await res.json();
},
enabled,
staleTime: 30 * 1000, // 30 seconds - auth status may change
});
}
// Export types using the standard inference pattern
export type DextoAuthStatus = NonNullable<ReturnType<typeof useDextoAuth>['data']>;

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch available providers and capabilities.
* Returns blob storage providers, compression strategies, custom tool providers, and internal tools.
*/
export function useDiscovery(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.discovery.all,
queryFn: async () => {
const res = await client.api.discovery.$get();
if (!res.ok) throw new Error('Failed to fetch discovery data');
return await res.json();
},
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes - providers don't change often
});
}
// Export types using the standard inference pattern
export type DiscoveryResponse = NonNullable<ReturnType<typeof useDiscovery>['data']>;
export type DiscoveredProvider = DiscoveryResponse['blob'][number];
export type InternalToolInfo = DiscoveryResponse['internalTools'][number];

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
// Returns true when the browser reports that page fonts are loaded.
// This lets us defer first-measure actions (like autosize) until
// typographic metrics are stable to avoid initial reflow.
export function useFontsReady(): boolean {
const [ready, setReady] = useState(false);
useEffect(() => {
if (typeof document === 'undefined') return;
// If Font Loading API is unavailable, assume ready to avoid blocking.
const anyDoc = document as any;
if (!anyDoc.fonts || !anyDoc.fonts.ready) {
setReady(true);
return;
}
let cancelled = false;
anyDoc.fonts.ready.then(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
return ready;
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
async function fetchGreeting(sessionId?: string | null): Promise<string | null> {
const data = await client.api.greeting.$get({
query: sessionId ? { sessionId } : {},
});
if (!data.ok) {
throw new Error(`Failed to fetch greeting: ${data.status}`);
}
const json = await data.json();
return json.greeting ?? null;
}
export function useGreeting(sessionId?: string | null) {
const {
data: greeting = null,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.greeting(sessionId),
queryFn: () => fetchGreeting(sessionId),
staleTime: 5 * 60 * 1000, // 5 minutes - greeting is static per agent
});
return { greeting, isLoading, error: error?.message ?? null };
}

View File

@@ -0,0 +1,187 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
import { isTextPart } from '../../types';
const MAX_HISTORY_SIZE = 100;
/**
* Hook to fetch user messages from session history
*/
function useSessionUserMessages(sessionId: string | null) {
return useQuery({
queryKey: queryKeys.sessions.history(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) return [];
const response = await client.api.sessions[':sessionId'].history.$get({
param: { sessionId },
});
if (!response.ok) return [];
const data = await response.json();
const historyMessages = data.history || [];
// Extract text content from user messages
const userTexts: string[] = [];
for (const msg of historyMessages) {
if (msg.role !== 'user') continue;
if (!msg.content || !Array.isArray(msg.content)) continue;
const textParts = msg.content
.filter(isTextPart)
.map((part) => part.text.trim())
.filter((t) => t.length > 0);
if (textParts.length > 0) {
userTexts.push(textParts.join('\n'));
}
}
// Deduplicate consecutive entries
const deduplicated: string[] = [];
for (const text of userTexts) {
if (deduplicated.length === 0 || deduplicated[deduplicated.length - 1] !== text) {
deduplicated.push(text);
}
}
return deduplicated.slice(-MAX_HISTORY_SIZE);
},
enabled: !!sessionId,
staleTime: 30000, // Consider fresh for 30s
});
}
/**
* Hook for managing input history with shell-style navigation.
*
* - Up arrow: Navigate to older entries
* - Down arrow: Navigate to newer entries
* - History cursor resets when user types new input
* - Loads previous user messages from session history via TanStack Query
*
*/
export function useInputHistory(sessionId: string | null) {
const queryClient = useQueryClient();
// Fetch historical user messages from session
const { data: history = [] } = useSessionUserMessages(sessionId);
// Current position in history (-1 means not browsing, 0 = oldest, length-1 = newest)
const [cursor, setCursor] = useState<number>(-1);
// Track the text that was in input before browsing started
const savedInputRef = useRef<string>('');
// Track last recalled text to prevent hijacking normal editing
const lastRecalledRef = useRef<string | null>(null);
// Reset cursor when session changes
useEffect(() => {
setCursor(-1);
lastRecalledRef.current = null;
savedInputRef.current = '';
}, [sessionId]);
/**
* Invalidate history cache after sending a message.
* Call this after successfully sending to refresh the history.
*/
const invalidateHistory = useCallback(() => {
if (sessionId) {
queryClient.invalidateQueries({
queryKey: queryKeys.sessions.history(sessionId),
});
}
}, [queryClient, sessionId]);
/**
* Check if we should handle navigation (up/down) vs normal cursor movement.
* Only handle navigation when:
* 1. Input is empty, OR
* 2. Cursor is at position 0 AND text matches last recalled history
*/
const shouldHandleNavigation = useCallback(
(currentText: string, cursorPosition: number): boolean => {
if (!currentText) return true;
if (cursorPosition === 0 && lastRecalledRef.current === currentText) {
return true;
}
return false;
},
[]
);
/**
* Navigate up (older entries)
* Returns the text to display, or null if at end of history
*/
const navigateUp = useCallback(
(currentText: string): string | null => {
if (history.length === 0) return null;
// If not currently browsing, save current input and start from newest
if (cursor === -1) {
savedInputRef.current = currentText;
const idx = history.length - 1;
setCursor(idx);
const text = history[idx];
lastRecalledRef.current = text ?? null;
return text ?? null;
}
// Already at oldest entry
if (cursor === 0) return null;
// Move to older entry
const newCursor = cursor - 1;
setCursor(newCursor);
const text = history[newCursor];
lastRecalledRef.current = text ?? null;
return text ?? null;
},
[history, cursor]
);
/**
* Navigate down (newer entries)
* Returns the text to display, or null if back to current input
*/
const navigateDown = useCallback((): string | null => {
if (cursor === -1) return null;
// At newest entry - return to saved input
if (cursor === history.length - 1) {
setCursor(-1);
lastRecalledRef.current = null;
return savedInputRef.current;
}
// Move to newer entry
const newCursor = cursor + 1;
setCursor(newCursor);
const text = history[newCursor];
lastRecalledRef.current = text ?? null;
return text ?? null;
}, [history, cursor]);
/**
* Reset history browsing (call when user types)
*/
const resetCursor = useCallback(() => {
if (cursor !== -1) {
setCursor(-1);
lastRecalledRef.current = null;
}
}, [cursor]);
return {
history,
cursor,
invalidateHistory,
navigateUp,
navigateDown,
resetCursor,
shouldHandleNavigation,
isBrowsing: cursor !== -1,
};
}

View File

@@ -0,0 +1,165 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useLLMCatalog(options?: { enabled?: boolean; mode?: 'grouped' | 'flat' }) {
const mode = options?.mode ?? 'grouped';
return useQuery({
queryKey: [...queryKeys.llm.catalog, mode],
queryFn: async () => {
const response = await client.api.llm.catalog.$get({ query: { mode } });
if (!response.ok) {
throw new Error(`Failed to fetch LLM catalog: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 5 * 60 * 1000, // 5 minutes - catalog rarely changes
});
}
export function useSwitchLLM() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: SwitchLLMPayload) => {
const response = await client.api.llm.switch.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to switch LLM: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
// Invalidate catalog and all current LLM queries to refresh all views
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
queryClient.invalidateQueries({ queryKey: ['llm', 'current'] });
},
});
}
export function useProviderApiKey(provider: LLMProvider | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...queryKeys.llm.catalog, 'key', provider],
queryFn: async () => {
if (!provider) return null;
const response = await client.api.llm.key[':provider'].$get({
param: { provider },
});
if (!response.ok) {
throw new Error(`Failed to fetch API key: ${response.status}`);
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!provider,
staleTime: 30 * 1000, // 30 seconds
});
}
export function useSaveApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: SaveApiKeyPayload) => {
const response = await client.api.llm.key.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to save API key: ${response.status}`);
}
return await response.json();
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
// Also invalidate the specific provider key query
queryClient.invalidateQueries({
queryKey: [...queryKeys.llm.catalog, 'key', variables.provider],
});
},
});
}
// Custom models hooks
export function useCustomModels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.llm.customModels,
queryFn: async () => {
const response = await client.api.llm['custom-models'].$get();
if (!response.ok) {
throw new Error(`Failed to fetch custom models: ${response.status}`);
}
const data = await response.json();
return data.models;
},
enabled: options?.enabled ?? true,
staleTime: 60 * 1000, // 1 minute
});
}
export function useCreateCustomModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: CustomModelPayload) => {
const response = await client.api.llm['custom-models'].$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create custom model: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
},
});
}
export function useDeleteCustomModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
// URL-encode the name to handle OpenRouter model IDs with slashes (e.g., anthropic/claude-3.5-sonnet)
const encodedName = encodeURIComponent(name);
const response = await client.api.llm['custom-models'][':name'].$delete({
param: { name: encodedName },
});
if (!response.ok) {
throw new Error(`Failed to delete custom model: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
},
});
}
// Model capabilities hook - resolves gateway providers to underlying model capabilities
export function useModelCapabilities(
provider: LLMProvider | null | undefined,
model: string | null | undefined,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: [...queryKeys.llm.catalog, 'capabilities', provider, model],
queryFn: async () => {
if (!provider || !model) return null;
const response = await client.api.llm.capabilities.$get({
query: { provider, model },
});
if (!response.ok) {
throw new Error(`Failed to fetch model capabilities: ${response.status}`);
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!provider && !!model,
staleTime: 5 * 60 * 1000, // 5 minutes - capabilities rarely change
});
}
// Export inferred types for components to use
export type SaveApiKeyPayload = Parameters<typeof client.api.llm.key.$post>[0]['json'];
export type LLMProvider = SaveApiKeyPayload['provider'];
export type SwitchLLMPayload = Parameters<typeof client.api.llm.switch.$post>[0]['json'];
// Helper to extract the custom-models endpoint (Prettier can't parse hyphenated bracket notation in Parameters<>)
type CustomModelsEndpoint = (typeof client.api.llm)['custom-models'];
export type CustomModelPayload = Parameters<CustomModelsEndpoint['$post']>[0]['json'];
export type CustomModel = NonNullable<ReturnType<typeof useCustomModels>['data']>[number];

View File

@@ -0,0 +1,56 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useMemories(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.memories.all,
queryFn: async () => {
const response = await client.api.memory.$get({ query: {} });
if (!response.ok) {
throw new Error(`Failed to fetch memories: ${response.status}`);
}
const data = await response.json();
return data.memories;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - memories can be added during chat
});
}
export function useDeleteMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ memoryId }: { memoryId: string }) => {
const response = await client.api.memory[':id'].$delete({ param: { id: memoryId } });
if (!response.ok) {
throw new Error(`Failed to delete memory: ${response.status}`);
}
return memoryId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
},
});
}
export function useCreateMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: Parameters<typeof client.api.memory.$post>[0]['json']) => {
const response = await client.api.memory.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create memory: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
},
});
}
// Export inferred types for components to use
export type Memory = NonNullable<ReturnType<typeof useMemories>['data']>[number];

View File

@@ -0,0 +1,114 @@
/**
* Hooks for local GGUF and Ollama model management.
*
* These hooks expose model discovery that was previously only available in CLI.
* Used by the model picker to display installed local models and Ollama models.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
/**
* Fetch installed local GGUF models from state.json.
* These are models downloaded via CLI or manually registered.
*/
export function useLocalModels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.models.local,
queryFn: async () => {
const response = await client.api.models.local.$get();
if (!response.ok) {
throw new Error(`Failed to fetch local models: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 30 * 1000, // 30 seconds - models don't change often
});
}
/**
* Fetch available Ollama models from the Ollama server.
* Returns empty list with available=false if Ollama is not running.
*/
export function useOllamaModels(options?: { enabled?: boolean; baseURL?: string }) {
return useQuery({
queryKey: queryKeys.models.ollama(options?.baseURL),
queryFn: async () => {
const response = await client.api.models.ollama.$get({
query: options?.baseURL ? { baseURL: options.baseURL } : {},
});
if (!response.ok) {
throw new Error(`Failed to fetch Ollama models: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 30 * 1000, // 30 seconds
retry: false, // Don't retry if Ollama not running
});
}
/**
* Validate a local GGUF file path.
* Used by the custom model form to validate file paths before saving.
*/
export function useValidateLocalFile() {
return useMutation({
mutationFn: async (filePath: string) => {
const response = await client.api.models.local.validate.$post({
json: { filePath },
});
if (!response.ok) {
throw new Error(`Failed to validate file: ${response.status}`);
}
return await response.json();
},
});
}
/**
* Delete an installed local model.
* Removes from state.json and optionally deletes the GGUF file from disk.
*/
export function useDeleteInstalledModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
modelId,
deleteFile = true,
}: {
modelId: string;
deleteFile?: boolean;
}) => {
const response = await client.api.models.local[':modelId'].$delete({
param: { modelId },
json: { deleteFile },
});
if (!response.ok) {
let errorMessage = `Failed to delete model: ${response.status}`;
try {
const data = await response.json();
if (data.error) errorMessage = data.error;
} catch {
// Response body not JSON-parseable (e.g., network error, proxy error), use default message
}
throw new Error(errorMessage);
}
return await response.json();
},
onSuccess: () => {
// Invalidate local models cache to refresh the list
queryClient.invalidateQueries({ queryKey: queryKeys.models.local });
},
});
}
// Export inferred types for components to use
export type LocalModel = NonNullable<ReturnType<typeof useLocalModels>['data']>['models'][number];
export type OllamaModel = NonNullable<ReturnType<typeof useOllamaModels>['data']>['models'][number];
export type ValidateLocalFileResult = Awaited<
ReturnType<ReturnType<typeof useValidateLocalFile>['mutateAsync']>
>;

View File

@@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { client } from '@/lib/client';
/**
* Validate an OpenRouter model ID against the registry.
* Returns validation result with status and optional error.
*/
export function useValidateOpenRouterModel() {
return useMutation({
mutationFn: async (modelId: string) => {
// URL-encode the model ID to handle slashes (e.g., anthropic/claude-3.5-sonnet)
const encodedModelId = encodeURIComponent(modelId);
const response = await client.api.openrouter.validate[':modelId'].$get({
param: { modelId: encodedModelId },
});
if (!response.ok) {
throw new Error(`Failed to validate model: ${response.status}`);
}
return await response.json();
},
});
}
// Export inferred types
export type ValidateOpenRouterModelResult = Awaited<
ReturnType<ReturnType<typeof useValidateOpenRouterModel>['mutateAsync']>
>;

View File

@@ -0,0 +1,69 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client.js';
/**
* Hook for fetching prompts with TanStack Query caching
*
* Replaces the old promptCache.ts in-memory cache with proper
* persistent caching that survives page refreshes.
*/
export function usePrompts(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.prompts.all,
queryFn: async () => {
const response = await client.api.prompts.$get();
const data = await response.json();
return data.prompts;
},
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
...options,
});
}
export function useCreatePrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.prompts.custom.$post>[0]['json']
) => {
const response = await client.api.prompts.custom.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create prompt: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
},
});
}
type ResolvePromptParams = Parameters<(typeof client.api.prompts)[':name']['resolve']['$get']>[0];
export function useResolvePrompt() {
return useMutation({
mutationFn: async (
payload: {
name: string;
} & ResolvePromptParams['query']
) => {
const { name, ...query } = payload;
const response = await client.api.prompts[':name'].resolve.$get({
param: { name: encodeURIComponent(name) },
query,
});
if (!response.ok) {
throw new Error(`Failed to resolve prompt: ${response.status}`);
}
return await response.json();
},
});
}
// Export inferred types for components to use
export type Prompt = NonNullable<ReturnType<typeof usePrompts>['data']>[number];

View File

@@ -0,0 +1,145 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch queued messages for a session
*/
export function useQueuedMessages(sessionId: string | null) {
return useQuery({
queryKey: queryKeys.queue.list(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) return { messages: [], count: 0 };
const response = await client.api.queue[':sessionId'].$get({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to fetch queued messages');
}
return await response.json();
},
enabled: !!sessionId,
// Refetch frequently while processing to show queue updates
refetchInterval: (query) => ((query.state.data?.count ?? 0) > 0 ? 2000 : false),
});
}
// Export type for queued message
export type QueuedMessage = NonNullable<
ReturnType<typeof useQueuedMessages>['data']
>['messages'][number];
/**
* Hook to queue a new message
*/
export function useQueueMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
sessionId,
message,
imageData,
fileData,
}: {
sessionId: string;
message?: string;
imageData?: { image: string; mimeType: string };
fileData?: { data: string; mimeType: string; filename?: string };
}) => {
// Build content parts array from text, image, and file data
// New API uses unified ContentInput = string | ContentPart[]
const contentParts: Array<
| { type: 'text'; text: string }
| { type: 'image'; image: string; mimeType?: string }
| { type: 'file'; data: string; mimeType: string; filename?: string }
> = [];
if (message) {
contentParts.push({ type: 'text', text: message });
}
if (imageData) {
contentParts.push({
type: 'image',
image: imageData.image,
mimeType: imageData.mimeType,
});
}
if (fileData) {
contentParts.push({
type: 'file',
data: fileData.data,
mimeType: fileData.mimeType,
filename: fileData.filename,
});
}
const response = await client.api.queue[':sessionId'].$post({
param: { sessionId },
json: {
content:
contentParts.length === 1 && contentParts[0]?.type === 'text'
? message! // Simple text-only case: send as string
: contentParts, // Multimodal: send as array
},
});
if (!response.ok) {
throw new Error('Failed to queue message');
}
return await response.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(variables.sessionId),
});
},
});
}
/**
* Hook to remove a single queued message
*/
export function useRemoveQueuedMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId, messageId }: { sessionId: string; messageId: string }) => {
const response = await client.api.queue[':sessionId'][':messageId'].$delete({
param: { sessionId, messageId },
});
if (!response.ok) {
throw new Error('Failed to remove queued message');
}
return await response.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(variables.sessionId),
});
},
});
}
/**
* Hook to clear all queued messages for a session
*/
export function useClearQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
const response = await client.api.queue[':sessionId'].$delete({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to clear queue');
}
return await response.json();
},
onSuccess: (_, sessionId) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(sessionId),
});
},
});
}

View File

@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { client } from '@/lib/client';
type NormalizedResourceItem =
| {
kind: 'text';
text: string;
mimeType?: string;
}
| {
kind: 'image';
src: string;
mimeType: string;
alt?: string;
}
| {
kind: 'audio';
src: string;
mimeType: string;
filename?: string;
}
| {
kind: 'video';
src: string;
mimeType: string;
filename?: string;
}
| {
kind: 'file';
src?: string;
mimeType?: string;
filename?: string;
};
export interface NormalizedResource {
uri: string;
name?: string;
meta?: Record<string, unknown>;
items: NormalizedResourceItem[];
}
export interface ResourceState {
status: 'loading' | 'loaded' | 'error';
data?: NormalizedResource;
error?: string;
}
type ResourceStateMap = Record<string, ResourceState>;
function buildDataUrl(base64: string, mimeType: string): string {
return `data:${mimeType};base64,${base64}`;
}
function normalizeResource(uri: string, payload: any): NormalizedResource {
const contents = Array.isArray(payload?.contents) ? payload.contents : [];
const meta = (payload?._meta ?? {}) as Record<string, unknown>;
const name =
(typeof meta.originalName === 'string' && meta.originalName.trim().length > 0
? meta.originalName
: undefined) || uri;
const items: NormalizedResourceItem[] = [];
for (const item of contents) {
if (!item || typeof item !== 'object') continue;
if (typeof (item as { text?: unknown }).text === 'string') {
items.push({
kind: 'text',
text: (item as { text: string }).text,
mimeType: typeof item.mimeType === 'string' ? item.mimeType : undefined,
});
continue;
}
const blobData = typeof item.blob === 'string' ? item.blob : undefined;
const rawData = typeof item.data === 'string' ? item.data : undefined;
const mimeType = typeof item.mimeType === 'string' ? item.mimeType : undefined;
const filename = typeof item.filename === 'string' ? item.filename : undefined;
if ((blobData || rawData) && mimeType) {
const base64 = blobData ?? rawData!;
const src = buildDataUrl(base64, mimeType);
if (mimeType.startsWith('image/')) {
items.push({
kind: 'image',
src,
mimeType,
alt: filename || name,
});
} else if (mimeType.startsWith('audio/')) {
items.push({
kind: 'audio',
src,
mimeType,
filename: filename || name,
});
} else if (mimeType.startsWith('video/')) {
items.push({
kind: 'video',
src,
mimeType,
filename: filename || name,
});
} else {
items.push({
kind: 'file',
src,
mimeType,
filename: filename || name,
});
}
continue;
}
if (mimeType && mimeType.startsWith('text/') && typeof item.value === 'string') {
items.push({
kind: 'text',
text: item.value,
mimeType,
});
}
}
return {
uri,
name,
meta,
items,
};
}
async function fetchResourceContent(uri: string): Promise<NormalizedResource> {
const response = await client.api.resources[':resourceId'].content.$get({
param: { resourceId: encodeURIComponent(uri) },
});
const body = await response.json();
const contentPayload = body?.content;
if (!contentPayload) {
throw new Error('No content returned for resource');
}
return normalizeResource(uri, contentPayload);
}
export function useResourceContent(resourceUris: string[]): ResourceStateMap {
// Serialize array for stable dependency comparison.
// Arrays are compared by reference in React, so ['a','b'] !== ['a','b'] even though
// values are identical. Serializing to 'a|b' allows value-based comparison to avoid
// unnecessary re-computation when parent passes new array reference with same contents.
const serializedUris = resourceUris.join('|');
const normalizedUris = useMemo(() => {
const seen = new Set<string>();
const ordered: string[] = [];
for (const uri of resourceUris) {
if (!uri || typeof uri !== 'string') continue;
const trimmed = uri.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
ordered.push(trimmed);
}
return ordered;
// We use resourceUris inside but only depend on serializedUris. This is safe because
// serializedUris is derived from resourceUris - when the string changes, the array
// values changed too. This is an intentional optimization to prevent re-runs when
// array reference changes but values remain the same.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serializedUris]);
const queries = useQueries({
queries: normalizedUris.map((uri) => ({
queryKey: ['resourceContent', uri],
queryFn: () => fetchResourceContent(uri),
enabled: !!uri,
retry: false,
})),
});
const resources: ResourceStateMap = useMemo(() => {
const result: ResourceStateMap = {};
queries.forEach((query, index) => {
const uri = normalizedUris[index];
if (!uri) return;
if (query.isLoading) {
result[uri] = { status: 'loading' };
} else if (query.error) {
result[uri] = {
status: 'error',
error: query.error instanceof Error ? query.error.message : String(query.error),
};
} else if (query.data) {
result[uri] = { status: 'loaded', data: query.data };
}
});
return result;
}, [queries, normalizedUris]);
return resources;
}
export type { NormalizedResourceItem };

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
async function fetchResources() {
const response = await client.api.resources.$get();
const data = await response.json();
if (!data.ok || !Array.isArray(data.resources)) {
throw new Error('Invalid response shape');
}
return data.resources;
}
export function useResources() {
const {
data: resources = [],
isLoading: loading,
error,
refetch: refresh,
} = useQuery({
queryKey: queryKeys.resources.all,
queryFn: fetchResources,
staleTime: 60 * 1000, // 1 minute - resources can change when servers connect/disconnect
});
return {
resources,
loading,
error: error?.message ?? null,
refresh: async () => {
await refresh();
},
} as const;
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
// Search messages
export function useSearchMessages(
query: string,
sessionId?: string,
limit: number = 50,
enabled: boolean = true
) {
return useQuery({
queryKey: queryKeys.search.messages(query, sessionId, limit),
queryFn: async () => {
const response = await client.api.search.messages.$get({
query: {
q: query,
limit: limit,
...(sessionId && { sessionId }),
},
});
return await response.json();
},
enabled: enabled && query.trim().length > 0,
staleTime: 30000, // 30 seconds
});
}
// Search sessions
export function useSearchSessions(query: string, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.search.sessions(query),
queryFn: async () => {
const response = await client.api.search.sessions.$get({
query: { q: query },
});
return await response.json();
},
enabled: enabled && query.trim().length > 0,
staleTime: 30000, // 30 seconds
});
}
// Export types inferred from hook return values
export type SearchResult = NonNullable<
ReturnType<typeof useSearchMessages>['data']
>['results'][number];
export type SessionSearchResult = NonNullable<
ReturnType<typeof useSearchSessions>['data']
>['results'][number];

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { serverRegistry } from '@/lib/serverRegistry';
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
import { queryKeys } from '@/lib/queryKeys.js';
interface UseServerRegistryOptions {
autoLoad?: boolean;
initialFilter?: ServerRegistryFilter;
}
export function useServerRegistry(options: UseServerRegistryOptions = {}) {
const { autoLoad = true, initialFilter } = options;
const queryClient = useQueryClient();
const [filter, setFilter] = useState<ServerRegistryFilter>(initialFilter || {});
const {
data: entries = [],
isLoading,
error,
} = useQuery({
queryKey: queryKeys.serverRegistry(filter),
queryFn: () => serverRegistry.getEntries(filter),
enabled: autoLoad,
});
const markAsInstalledMutation = useMutation({
mutationFn: async (entryId: string) => {
await serverRegistry.setInstalled(entryId, true);
return entryId;
},
onSuccess: (entryId) => {
// Optimistically update the cache
queryClient.setQueryData<ServerRegistryEntry[]>(
queryKeys.serverRegistry(filter),
(old) =>
old?.map((entry) =>
entry.id === entryId ? { ...entry, isInstalled: true } : entry
) ?? []
);
},
});
const updateFilter = (newFilter: ServerRegistryFilter) => {
setFilter(newFilter);
};
const loadEntries = async (newFilter?: ServerRegistryFilter) => {
if (newFilter) {
setFilter(newFilter);
} else {
// Trigger a refetch with current filter
await queryClient.refetchQueries({ queryKey: queryKeys.serverRegistry(filter) });
}
};
const markAsInstalled = async (entryId: string) => {
await markAsInstalledMutation.mutateAsync(entryId);
};
return {
entries,
isLoading,
error: error?.message ?? null,
filter,
loadEntries,
updateFilter,
markAsInstalled,
clearError: () => {
// Errors are automatically cleared when query succeeds
},
};
}

View File

@@ -0,0 +1,109 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
export function useServers(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.servers.all,
queryFn: async () => {
const res = await client.api.mcp.servers.$get();
if (!res.ok) {
throw new Error('Failed to fetch servers');
}
const data = await res.json();
// Type is inferred from Hono client response schema
return data.servers;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - server status can change
});
}
export function useServerTools(serverId: string | null, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.servers.tools(serverId || ''),
queryFn: async () => {
if (!serverId) return [];
const res = await client.api.mcp.servers[':serverId'].tools.$get({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to fetch tools');
}
const data = await res.json();
// Type is inferred from Hono client response schema
return data.tools;
},
enabled: enabled && !!serverId,
staleTime: 2 * 60 * 1000, // 2 minutes - tools don't change once server is connected
});
}
// Add new MCP server
export function useAddServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: Parameters<typeof client.api.mcp.servers.$post>[0]['json']) => {
const res = await client.api.mcp.servers.$post({ json: payload });
if (!res.ok) {
const error = await res.text();
throw new Error(error || 'Failed to add server');
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
},
});
}
// Delete MCP server
export function useDeleteServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serverId: string) => {
const res = await client.api.mcp.servers[':serverId'].$delete({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to delete server');
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
},
});
}
// Restart MCP server
export function useRestartServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serverId: string) => {
const res = await client.api.mcp.servers[':serverId'].restart.$post({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to restart server');
}
return serverId;
},
onSuccess: (serverId) => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
// Invalidate tools for this server as they may have changed after restart
queryClient.invalidateQueries({ queryKey: queryKeys.servers.tools(serverId) });
},
});
}
// Export types inferred from hook return values
export type McpServer = NonNullable<ReturnType<typeof useServers>['data']>[number];
export type McpTool = NonNullable<ReturnType<typeof useServerTools>['data']>[number];

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
export function useSessions(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.sessions.all,
queryFn: async () => {
const response = await client.api.sessions.$get();
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.status}`);
}
const data = await response.json();
return data.sessions;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - sessions can be created frequently
});
}
// Create a new session
export function useCreateSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId }: { sessionId?: string }) => {
const response = await client.api.sessions.$post({
json: { sessionId: sessionId?.trim() || undefined },
});
if (!response.ok) {
throw new Error(`Failed to create session: ${response.status}`);
}
const data = await response.json();
return data.session;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Delete a session
export function useDeleteSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId }: { sessionId: string }) => {
const response = await client.api.sessions[':sessionId'].$delete({
param: { sessionId },
});
if (!response.ok) {
throw new Error(`Failed to delete session: ${response.status}`);
}
},
onSuccess: () => {
// Invalidate sessions list to refresh after deletion
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Rename a session (update title)
export function useRenameSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId, title }: { sessionId: string; title: string }) => {
const response = await client.api.sessions[':sessionId'].$patch({
param: { sessionId },
json: { title },
});
if (!response.ok) {
throw new Error('Failed to rename session');
}
const data = await response.json();
return data.session;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Export inferred types for components to use
export type Session = NonNullable<ReturnType<typeof useSessions>['data']>[number];

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
export function useTheme() {
// Initialize from SSR-provided class on <html> to avoid flicker
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof document !== 'undefined') {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
// Match SSR default from layout (dark)
return 'dark';
});
// Sync DOM class, localStorage and cookie when theme changes
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', theme === 'dark');
try {
localStorage.setItem('theme', theme);
const isSecure =
typeof window !== 'undefined' && window.location?.protocol === 'https:';
document.cookie = `theme=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax${isSecure ? '; Secure' : ''}`;
} catch {
// Ignore storage errors in restrictive environments
}
}, [theme]);
const toggleTheme = (checked: boolean) => {
setTheme(checked ? 'dark' : 'light');
};
// Keep API shape backward-compatible
return { theme, toggleTheme, hasMounted: true } as const;
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
export function useAllTools(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.tools.all,
queryFn: async () => {
const res = await client.api.tools.$get();
if (!res.ok) throw new Error('Failed to fetch tools');
return await res.json();
},
enabled,
});
}
// Export types using the standard inference pattern
export type AllToolsResponse = NonNullable<ReturnType<typeof useAllTools>['data']>;
export type ToolInfo = AllToolsResponse['tools'][number];

View File

@@ -0,0 +1,199 @@
/**
* Event Bus Provider
*
* Provides the event bus context to the React component tree.
* Initializes middleware and provides access to the singleton event bus.
*/
import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
import {
ClientEventBus,
eventBus,
loggingMiddleware,
activityMiddleware,
notificationMiddleware,
setupEventHandlers,
type EventMiddleware,
} from '@/lib/events';
/**
* Event bus context value
*/
interface EventBusContextValue {
/** The event bus instance */
bus: ClientEventBus;
}
const EventBusContext = createContext<EventBusContextValue | null>(null);
/**
* Props for EventBusProvider
*/
interface EventBusProviderProps {
children: ReactNode;
/**
* Additional middleware to register (beyond default logging)
* Middleware is executed in array order
*/
middleware?: EventMiddleware[];
/**
* Enable logging middleware (default: true in development)
*/
enableLogging?: boolean;
/**
* Enable activity tracking middleware (default: true)
*/
enableActivityLogging?: boolean;
/**
* Enable notification middleware (default: true)
*/
enableNotifications?: boolean;
/**
* Custom event bus instance (for testing)
* If not provided, uses the singleton instance
*/
bus?: ClientEventBus;
}
/**
* Event Bus Provider
*
* Wraps the application with event bus context.
* Initializes default middleware on mount.
*
* @example
* ```tsx
* // Basic usage
* <EventBusProvider>
* <App />
* </EventBusProvider>
*
* // With all middleware disabled except custom
* <EventBusProvider
* enableLogging={false}
* enableActivityLogging={false}
* middleware={[customMiddleware]}
* >
* <App />
* </EventBusProvider>
*
* // For testing with isolated bus
* <EventBusProvider bus={testBus}>
* <ComponentUnderTest />
* </EventBusProvider>
* ```
*/
export function EventBusProvider({
children,
middleware = [],
enableLogging = process.env.NODE_ENV === 'development',
enableActivityLogging = true,
enableNotifications = true,
bus = eventBus,
}: EventBusProviderProps) {
// Register middleware and handlers on mount
useEffect(() => {
const registeredMiddleware: EventMiddleware[] = [];
let handlerCleanup: (() => void) | undefined;
// Register middleware in order (logging first for debugging)
if (enableLogging) {
bus.use(loggingMiddleware);
registeredMiddleware.push(loggingMiddleware);
}
if (enableActivityLogging) {
bus.use(activityMiddleware);
registeredMiddleware.push(activityMiddleware);
}
if (enableNotifications) {
bus.use(notificationMiddleware);
registeredMiddleware.push(notificationMiddleware);
}
// Add custom middleware
for (const mw of middleware) {
bus.use(mw);
registeredMiddleware.push(mw);
}
// Setup event handlers
handlerCleanup = setupEventHandlers(bus);
// Cleanup on unmount
return () => {
for (const mw of registeredMiddleware) {
bus.removeMiddleware(mw);
}
handlerCleanup?.();
};
}, [bus, enableLogging, enableActivityLogging, enableNotifications, middleware]);
// Memoize context value
const contextValue = useMemo<EventBusContextValue>(() => ({ bus }), [bus]);
return <EventBusContext.Provider value={contextValue}>{children}</EventBusContext.Provider>;
}
/**
* Hook to access the event bus
*
* @returns The event bus instance
* @throws Error if used outside EventBusProvider
*
* @example
* ```tsx
* function MyComponent() {
* const bus = useEventBus();
*
* useEffect(() => {
* const sub = bus.on('llm:response', (event) => {
* console.log('Response:', event.content);
* });
* return () => sub.unsubscribe();
* }, [bus]);
* }
* ```
*/
export function useEventBus(): ClientEventBus {
const context = useContext(EventBusContext);
if (!context) {
throw new Error('useEventBus must be used within an EventBusProvider');
}
return context.bus;
}
/**
* Hook to subscribe to events with automatic cleanup
*
* @param eventName - Event name to subscribe to
* @param handler - Handler function
*
* @example
* ```tsx
* function MessageList() {
* const [messages, setMessages] = useState<Message[]>([]);
*
* useEventSubscription('llm:response', (event) => {
* setMessages(prev => [...prev, {
* role: 'assistant',
* content: event.content,
* }]);
* });
* }
* ```
*/
export function useEventSubscription<T extends Parameters<ClientEventBus['on']>[0]>(
eventName: T,
handler: Parameters<ClientEventBus['on']>[1]
): void {
const bus = useEventBus();
useEffect(() => {
// Type assertion is needed here because the generic relationship between
// eventName and handler is complex - the EventBus.on() validates at runtime
const subscription = bus.on(eventName as any, handler as any);
return () => subscription.unsubscribe();
}, [bus, eventName, handler]);
}

View File

@@ -0,0 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,114 @@
import { Key, Volume2, Palette, X, ChevronDown } from 'lucide-react';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { cn } from '@/lib/utils';
export type SettingsSection = 'api-keys' | 'voice' | 'appearance';
type SettingsNavigationProps = {
activeSection: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
onClose: () => void;
variant?: 'desktop' | 'mobile';
};
const sections: { id: SettingsSection; label: string; icon: typeof Key }[] = [
{ id: 'api-keys', label: 'API Keys', icon: Key },
{ id: 'voice', label: 'Voice & TTS', icon: Volume2 },
{ id: 'appearance', label: 'Appearance', icon: Palette },
];
export function SettingsNavigation({
activeSection,
onSectionChange,
onClose,
variant = 'desktop',
}: SettingsNavigationProps) {
if (variant === 'mobile') {
const activeItem = sections.find((s) => s.id === activeSection);
const ActiveIcon = activeItem?.icon || Key;
return (
<div className="flex items-center justify-between w-full">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<ActiveIcon className="h-4 w-4" />
<span>{activeItem?.label}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{sections.map((section) => {
const Icon = section.icon;
return (
<DropdownMenuItem
key={section.id}
onClick={() => onSectionChange(section.id)}
className={cn(
'flex items-center gap-2',
activeSection === section.id && 'bg-accent'
)}
>
<Icon className="h-4 w-4" />
<span>{section.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close settings">
<X className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-border">
<h2 className="font-semibold">Settings</h2>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onClose}
aria-label="Close settings"
>
<X className="h-4 w-4" />
</Button>
</div>
<nav className="flex-1 py-2">
{sections.map((section) => {
const Icon = section.icon;
const isActive = activeSection === section.id;
return (
<button
type="button"
key={section.id}
onClick={() => onSectionChange(section.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground'
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span>{section.label}</span>
</button>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import { SettingsNavigation, type SettingsSection } from './SettingsNavigation';
import { ApiKeysSection } from './sections/ApiKeysSection';
import { VoiceSection } from './sections/VoiceSection';
import { AppearanceSection } from './sections/AppearanceSection';
type SettingsPanelProps = {
isOpen: boolean;
onClose: () => void;
};
const sectionTitles: Record<SettingsSection, string> = {
'api-keys': 'API Keys',
voice: 'Voice & TTS',
appearance: 'Appearance',
};
const sectionDescriptions: Record<SettingsSection, string> = {
'api-keys': 'Manage API keys for LLM providers',
voice: 'Configure text-to-speech settings',
appearance: 'Customize theme and UI preferences',
};
export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('api-keys');
const renderSection = () => {
switch (activeSection) {
case 'api-keys':
return <ApiKeysSection />;
case 'voice':
return <VoiceSection active={isOpen} />;
case 'appearance':
return <AppearanceSection />;
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-w-4xl! w-[90vw] h-[85vh] p-0! gap-0! flex flex-col"
hideCloseButton
>
{/* Visually hidden title for accessibility */}
<DialogTitle className="sr-only">Settings</DialogTitle>
<div className="flex flex-1 min-h-0">
{/* Left Navigation - hidden on mobile, shown on md+ */}
<div className="hidden md:flex md:flex-col w-56 border-r border-border bg-muted/30 shrink-0">
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
onClose={onClose}
/>
</div>
{/* Right Content Area */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{/* Mobile header with navigation */}
<div className="md:hidden flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
onClose={onClose}
variant="mobile"
/>
</div>
{/* Desktop header */}
<div className="hidden md:block px-6 py-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold">
{sectionTitles[activeSection]}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{sectionDescriptions[activeSection]}
</p>
</div>
{/* Section content - scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
{renderSection()}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,300 @@
import { useState } from 'react';
import {
useLLMCatalog,
useSaveApiKey,
useProviderApiKey,
type LLMProvider,
} from '../../hooks/useLLM';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Label } from '../../ui/label';
import { Alert, AlertDescription } from '../../ui/alert';
import { Check, Eye, EyeOff, ExternalLink, Loader2 } from 'lucide-react';
// Provider info with display names and key URLs
const PROVIDER_INFO: Record<
string,
{ displayName: string; keyUrl?: string; description?: string }
> = {
openai: {
displayName: 'OpenAI',
keyUrl: 'https://platform.openai.com/api-keys',
description: 'GPT models',
},
anthropic: {
displayName: 'Anthropic',
keyUrl: 'https://console.anthropic.com/settings/keys',
description: 'Claude models',
},
google: {
displayName: 'Google AI',
keyUrl: 'https://aistudio.google.com/apikey',
description: 'Gemini models (Free tier available)',
},
groq: {
displayName: 'Groq',
keyUrl: 'https://console.groq.com/keys',
description: 'Fast inference',
},
xai: {
displayName: 'xAI',
keyUrl: 'https://console.x.ai/team/default/api-keys',
description: 'Grok models',
},
cohere: {
displayName: 'Cohere',
keyUrl: 'https://dashboard.cohere.com/api-keys',
description: 'Command models',
},
openrouter: {
displayName: 'OpenRouter',
keyUrl: 'https://openrouter.ai/keys',
description: 'Multi-provider gateway',
},
glama: {
displayName: 'Glama',
keyUrl: 'https://glama.ai/settings/api-keys',
description: 'OpenAI-compatible',
},
ollama: {
displayName: 'Ollama',
description: 'Local models (no key needed)',
},
local: {
displayName: 'Local',
description: 'GGUF models (no key needed)',
},
};
// Providers that don't need API keys or need special configuration
// These are handled by the ModelPicker's custom model form instead
const EXCLUDED_PROVIDERS = [
'ollama', // Local, no key needed
'local', // Local GGUF, no key needed
'openai-compatible', // Needs baseURL + model name (use ModelPicker)
'litellm', // Needs baseURL (use ModelPicker)
'bedrock', // Uses AWS credentials, not API key
'vertex', // Uses Google Cloud ADC, not API key
];
type ProviderRowProps = {
provider: LLMProvider;
hasKey: boolean;
envVar: string;
onSave: (key: string) => Promise<void>;
};
function ProviderRow({ provider, hasKey, envVar, onSave }: ProviderRowProps) {
const [isEditing, setIsEditing] = useState(false);
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const info = PROVIDER_INFO[provider] || { displayName: provider };
// Query for masked key value when has key
const { data: keyData } = useProviderApiKey(hasKey ? provider : null);
const handleSave = async () => {
if (!apiKey.trim()) {
setError('API key is required');
return;
}
setError(null);
setIsSaving(true);
try {
await onSave(apiKey);
setApiKey('');
setIsEditing(false);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setIsEditing(false);
setApiKey('');
setError(null);
};
return (
<div className="py-3 px-4 rounded-lg border border-border">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{info.displayName}</span>
{hasKey && !isEditing && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
Configured
</span>
)}
{saveSuccess && (
<span className="text-xs text-green-600 dark:text-green-400">
Saved!
</span>
)}
</div>
<div className="text-sm text-muted-foreground">{info.description}</div>
{hasKey && keyData?.keyValue && !isEditing && (
<div className="mt-1 text-xs text-muted-foreground font-mono">
{keyData.keyValue}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{info.keyUrl && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => window.open(info.keyUrl, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
{!isEditing ? (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
{hasKey ? 'Update' : 'Add Key'}
</Button>
) : null}
</div>
</div>
{isEditing && (
<div className="mt-3 space-y-3">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">API Key ({envVar})</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Enter ${info.displayName} API key`}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
{error && (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-sm">{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</div>
</div>
)}
</div>
);
}
export function ApiKeysSection() {
const { data: catalog, isLoading, error } = useLLMCatalog({ mode: 'grouped' });
const { mutateAsync: saveApiKey } = useSaveApiKey();
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>Failed to load providers: {error.message}</AlertDescription>
</Alert>
);
}
if (!catalog || !('providers' in catalog)) {
return (
<Alert>
<AlertDescription>No providers available</AlertDescription>
</Alert>
);
}
const providers = Object.entries(catalog.providers) as [
LLMProvider,
{ hasApiKey: boolean; primaryEnvVar: string },
][];
// Filter out providers handled elsewhere (openai-compatible is in Default Model)
const regularProviders = providers.filter(([id]) => !EXCLUDED_PROVIDERS.includes(id));
// Sort: configured first, then by display name
const sortedProviders = regularProviders.sort((a, b) => {
const aHasKey = a[1].hasApiKey;
const bHasKey = b[1].hasApiKey;
if (aHasKey !== bHasKey) return bHasKey ? 1 : -1;
const aName = PROVIDER_INFO[a[0]]?.displayName || a[0];
const bName = PROVIDER_INFO[b[0]]?.displayName || b[0];
return aName.localeCompare(bName);
});
const handleSave = async (provider: LLMProvider, apiKey: string) => {
await saveApiKey({ provider, apiKey });
};
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
API keys are stored securely in your local .env file and are never shared with third
parties.
</p>
{sortedProviders.map(([provider, info]) => (
<ProviderRow
key={provider}
provider={provider}
hasKey={info.hasApiKey}
envVar={info.primaryEnvVar}
onSave={(key) => handleSave(provider, key)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useTheme } from '../../hooks/useTheme';
import { usePreferenceStore } from '@/lib/stores/preferenceStore';
import { Label } from '../../ui/label';
import { Switch } from '../../ui/switch';
import { Moon, Sun, Zap } from 'lucide-react';
export function AppearanceSection() {
const { theme, toggleTheme } = useTheme();
const { isStreaming, setStreaming } = usePreferenceStore();
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Customize the look and feel of the application.
</p>
{/* Theme Setting */}
<div className="space-y-4">
<div className="flex items-center justify-between py-3 px-4 rounded-lg border border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-muted">
{theme === 'dark' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</div>
<div>
<Label className="text-sm font-medium">Dark Mode</Label>
<p className="text-sm text-muted-foreground">
Switch between light and dark themes
</p>
</div>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
aria-label="Toggle dark mode"
/>
</div>
{/* Streaming Setting */}
<div className="flex items-center justify-between py-3 px-4 rounded-lg border border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-muted">
<Zap className="h-4 w-4" />
</div>
<div>
<Label className="text-sm font-medium">Streaming Responses</Label>
<p className="text-sm text-muted-foreground">
Show responses as they are generated (recommended)
</p>
</div>
</div>
<Switch
checked={isStreaming}
onCheckedChange={setStreaming}
aria-label="Toggle streaming mode"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { Label } from '../../ui/label';
import { SpeechVoiceSelect } from '../../ui/speech-voice-select';
type VoiceSectionProps = {
active?: boolean;
};
export function VoiceSection({ active = false }: VoiceSectionProps) {
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Configure text-to-speech settings for voice output.
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="voice-select">Voice Selection</Label>
<p className="text-sm text-muted-foreground mb-3">
Choose a preferred text-to-speech voice. "Auto" selects the best available
voice on your device.
</p>
<SpeechVoiceSelect id="voice-select" active={active} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,334 @@
/**
* DiffRenderer Component
*
* Renders unified diff with syntax highlighting.
* Shows filename, +N/-M stats, and colored diff lines.
*/
import { useState } from 'react';
import { FileEdit, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { DiffDisplayData } from '@dexto/core';
interface DiffRendererProps {
/** Diff display data from tool result */
data: DiffDisplayData;
/** Maximum lines before truncation (default: 50) */
maxLines?: number;
/** Whether to start expanded (default: false) */
defaultExpanded?: boolean;
}
// =============================================================================
// Diff Parsing (ported from CLI)
// =============================================================================
interface ParsedHunk {
oldStart: number;
newStart: number;
lines: ParsedLine[];
}
interface ParsedLine {
type: 'context' | 'addition' | 'deletion';
content: string;
lineNum: number;
}
/**
* Parse unified diff into structured hunks.
*/
function parseUnifiedDiff(unified: string): ParsedHunk[] {
const lines = unified.split('\n');
const hunks: ParsedHunk[] = [];
let currentHunk: ParsedHunk | null = null;
let oldLine = 0;
let newLine = 0;
for (const line of lines) {
if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('Index:')) {
continue;
}
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (hunkMatch) {
if (currentHunk) {
hunks.push(currentHunk);
}
oldLine = parseInt(hunkMatch[1]!, 10);
newLine = parseInt(hunkMatch[3]!, 10);
currentHunk = {
oldStart: oldLine,
newStart: newLine,
lines: [],
};
continue;
}
if (!currentHunk) continue;
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.slice(1),
lineNum: newLine++,
});
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.slice(1),
lineNum: oldLine++,
});
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.startsWith(' ') ? line.slice(1) : line,
lineNum: newLine,
});
oldLine++;
newLine++;
}
}
if (currentHunk) {
hunks.push(currentHunk);
}
return hunks;
}
/**
* Get line number width for consistent alignment.
*/
function getLineNumWidth(maxLineNum: number): number {
return Math.max(3, String(maxLineNum).length);
}
/**
* Format line number with padding.
*/
function formatLineNum(num: number, width: number): string {
return String(num).padStart(width, ' ');
}
// =============================================================================
// Line Components
// =============================================================================
interface DiffLineProps {
type: 'context' | 'addition' | 'deletion';
lineNum: number;
lineNumWidth: number;
content: string;
}
/**
* Render a single diff line with gutter and content.
*/
function DiffLine({ type, lineNum, lineNumWidth, content }: DiffLineProps) {
const lineNumStr = formatLineNum(lineNum, lineNumWidth);
const getStyles = () => {
switch (type) {
case 'deletion':
return {
bg: 'bg-red-100/50 dark:bg-red-900/20',
text: 'text-red-800 dark:text-red-300',
symbol: '-',
symbolColor: 'text-red-600 dark:text-red-400',
};
case 'addition':
return {
bg: 'bg-green-100/50 dark:bg-green-900/20',
text: 'text-green-800 dark:text-green-300',
symbol: '+',
symbolColor: 'text-green-600 dark:text-green-400',
};
default:
return {
bg: '',
text: 'text-foreground/60',
symbol: ' ',
symbolColor: 'text-transparent',
};
}
};
const styles = getStyles();
return (
<div className={cn('flex font-mono text-[11px] leading-5', styles.bg)}>
{/* Gutter: line number + symbol */}
<div className="flex-shrink-0 select-none">
<span className="text-muted-foreground/50 px-1">{lineNumStr}</span>
<span className={cn('px-0.5', styles.symbolColor)}>{styles.symbol}</span>
</div>
{/* Content */}
<pre className={cn('flex-1 px-1 whitespace-pre-wrap break-all', styles.text)}>
{content || ' '}
</pre>
</div>
);
}
/**
* Hunk separator.
*/
function HunkSeparator() {
return (
<div className="text-muted-foreground text-[10px] py-0.5 px-2 bg-muted/20">
<span className="text-muted-foreground/60">···</span>
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
/**
* Extract relative path from full path.
*/
function getRelativePath(path: string): string {
const parts = path.split('/').filter(Boolean);
if (parts.length <= 3) return path;
return `.../${parts.slice(-3).join('/')}`;
}
/**
* Renders unified diff with syntax highlighting and line numbers.
*/
export function DiffRenderer({ data, maxLines = 50, defaultExpanded = false }: DiffRendererProps) {
const { unified, filename, additions, deletions } = data;
const [expanded, setExpanded] = useState(defaultExpanded);
const [showAll, setShowAll] = useState(false);
const [copied, setCopied] = useState(false);
const hunks = parseUnifiedDiff(unified);
// Calculate max line number for width
let maxLineNum = 1;
let totalLines = 0;
for (const hunk of hunks) {
for (const line of hunk.lines) {
maxLineNum = Math.max(maxLineNum, line.lineNum);
totalLines++;
}
}
const lineNumWidth = getLineNumWidth(maxLineNum);
const shouldTruncate = totalLines > maxLines && !showAll;
const handleCopy = async () => {
await navigator.clipboard.writeText(unified);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-1.5">
{/* Header with filename and stats */}
<div className="flex items-center gap-2 flex-wrap">
<FileEdit className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs text-foreground/80 truncate" title={filename}>
{getRelativePath(filename)}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium text-green-600 dark:text-green-400">
+{additions}
</span>
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
-{deletions}
</span>
</div>
</div>
{/* Diff content */}
<div className="pl-5">
{!expanded ? (
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className="h-3 w-3" />
<span>
Show diff ({totalLines} line{totalLines !== 1 ? 's' : ''})
</span>
</button>
) : (
<div className="space-y-1">
<div className="flex items-center justify-between">
<button
onClick={() => setExpanded(false)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown className="h-3 w-3" />
<span>Diff</span>
</button>
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<>
<Check className="h-3 w-3 text-green-500" />
<span>Copied</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
<span>Copy</span>
</>
)}
</button>
</div>
<div className="bg-muted/30 rounded-md overflow-hidden border border-border/50">
<div className="max-h-96 overflow-y-auto scrollbar-thin">
{(() => {
let linesRendered = 0;
return hunks.map((hunk, hunkIndex) => {
if (shouldTruncate && linesRendered >= maxLines) {
return null;
}
return (
<div key={hunkIndex}>
{hunkIndex > 0 && <HunkSeparator />}
{hunk.lines.map((line, lineIndex) => {
if (
shouldTruncate &&
linesRendered >= maxLines
) {
return null;
}
linesRendered++;
return (
<DiffLine
key={`${hunkIndex}-${lineIndex}`}
type={line.type}
lineNum={line.lineNum}
lineNumWidth={lineNumWidth}
content={line.content}
/>
);
})}
</div>
);
});
})()}
</div>
{shouldTruncate && (
<button
onClick={() => setShowAll(true)}
className="w-full py-2 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 bg-muted/50 border-t border-border/50"
>
Show {totalLines - maxLines} more lines...
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
/**
* FileRenderer Component
*
* Renders file operation metadata (read, write, create, delete).
* Compact single-line format with operation badge.
*/
import { FileText, FilePlus, FileX, FileEdit } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { FileDisplayData } from '@dexto/core';
interface FileRendererProps {
/** File display data from tool result */
data: FileDisplayData;
}
/**
* Get operation icon and color based on operation type.
*/
function getOperationInfo(operation: FileDisplayData['operation']) {
switch (operation) {
case 'read':
return {
icon: FileText,
label: 'Read',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
};
case 'write':
return {
icon: FileEdit,
label: 'Updated',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
};
case 'create':
return {
icon: FilePlus,
label: 'Created',
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30',
};
case 'delete':
return {
icon: FileX,
label: 'Deleted',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-100 dark:bg-red-900/30',
};
}
}
/**
* Format file size in human-readable format.
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Extract relative path (last 2-3 segments) from full path.
*/
function getRelativePath(path: string): string {
const parts = path.split('/').filter(Boolean);
if (parts.length <= 3) return path;
return `.../${parts.slice(-3).join('/')}`;
}
/**
* Renders file operation summary as a compact single-line card.
*/
export function FileRenderer({ data }: FileRendererProps) {
const { path, operation, size, lineCount } = data;
const opInfo = getOperationInfo(operation);
const Icon = opInfo.icon;
const metadata: string[] = [];
if (lineCount !== undefined) {
metadata.push(`${lineCount} line${lineCount !== 1 ? 's' : ''}`);
}
if (size !== undefined) {
metadata.push(formatSize(size));
}
return (
<div className="flex items-center gap-2 py-1">
{/* Operation badge */}
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium',
opInfo.bgColor,
opInfo.color
)}
>
<Icon className="h-3 w-3" />
<span>{opInfo.label}</span>
</div>
{/* File path */}
<span className="font-mono text-xs text-foreground/80 truncate" title={path}>
{getRelativePath(path)}
</span>
{/* Metadata */}
{metadata.length > 0 && (
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
({metadata.join(', ')})
</span>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More