mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: implement custom slash commands with modal-based creation
- Add CustomCommandsSettings component with row-based design matching Mode section - Integrate AddCustomCommandModal using proper Dialog components for consistent styling - Remove category filtering and inline editing for cleaner UX - Update modal to use proper Dialog background instead of dark overlay - Remove category field from modal form for simplified command creation - Apply subtle text colors to form helper text for better visual hierarchy - Remove preview section from modal to reduce height and improve usability - Add proper header styling with CardTitle and CardDescription - Update command icons to display without background circles - Implement proper padding and spacing throughout the interface - Connect modal functionality to Plus button and edit actions - Preserve existing command categories while preventing new category assignment
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Zap, Code, FileText, Search, Play, Settings, Hash } from 'lucide-react';
|
||||
import { Plus, Zap, Code, FileText, Search, Play, Settings, Hash } from 'lucide-react';
|
||||
import { CustomCommand } from '../types/customCommands';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface AddCustomCommandModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,7 +33,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: 'Zap',
|
||||
category: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -45,7 +46,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
description: editingCommand.description,
|
||||
prompt: editingCommand.prompt,
|
||||
icon: editingCommand.icon || 'Zap',
|
||||
category: editingCommand.category || '',
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
@@ -54,7 +54,6 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
description: '',
|
||||
prompt: '',
|
||||
icon: 'Zap',
|
||||
category: '',
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
@@ -100,7 +99,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
description: formData.description.trim(),
|
||||
prompt: formData.prompt.trim(),
|
||||
icon: formData.icon,
|
||||
category: formData.category.trim() || undefined,
|
||||
category: editingCommand?.category || undefined,
|
||||
createdAt: editingCommand?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
usageCount: editingCommand?.usageCount || 0,
|
||||
@@ -129,33 +128,23 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-background-default border border-borderStandard rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-borderStandard">
|
||||
<h2 className="text-lg font-semibold text-textStandard">
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingCommand ? 'Edit Custom Command' : 'Add Custom Command'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-bgSubtle rounded-md transition-colors"
|
||||
>
|
||||
<X size={20} className="text-textSubtle" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
<div className="py-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Command Name */}
|
||||
<div>
|
||||
@@ -179,7 +168,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-textSubtle">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
This will be the command users type (e.g., /document)
|
||||
</p>
|
||||
</div>
|
||||
@@ -201,7 +190,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
{errors.label && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.label}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-textSubtle">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Friendly name shown in the command list
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,22 +237,7 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => handleInputChange('category', e.target.value)}
|
||||
placeholder="Documentation, Code, etc."
|
||||
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard placeholder-textSubtle focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-textSubtle">
|
||||
Optional category for organization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
@@ -282,52 +256,26 @@ export const AddCustomCommandModal: React.FC<AddCustomCommandModalProps> = ({
|
||||
{errors.prompt && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.prompt}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-textSubtle">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
This is the full prompt that will be sent to the AI when the command is used
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="bg-bgSubtle rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-textStandard mb-2">Preview</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-textSubtle">User types:</div>
|
||||
<div className="font-mono text-sm bg-background-default px-2 py-1 rounded border">
|
||||
/{formData.name || 'command'}
|
||||
</div>
|
||||
<div className="text-xs text-textSubtle">Appears as pill:</div>
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full text-sm">
|
||||
{iconOptions.find(opt => opt.name === formData.icon)?.icon}
|
||||
{formData.label || 'Command Label'}
|
||||
</div>
|
||||
<div className="text-xs text-textSubtle">AI receives:</div>
|
||||
<div className="text-sm bg-background-default px-2 py-1 rounded border max-h-20 overflow-y-auto">
|
||||
{formData.prompt || 'Your prompt will appear here...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-borderStandard">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-textStandard hover:bg-bgSubtle rounded-md transition-colors"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center gap-2"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
{editingCommand ? 'Update Command' : 'Add Command'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,14 +2,11 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, Copy, Download, Upload, Star, StarOff, Zap, Code, FileText, Search } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Label } from '../ui/label';
|
||||
import { AddCustomCommandModal } from '../AddCustomCommandModal';
|
||||
import {
|
||||
CustomCommand,
|
||||
CustomCommandCategory,
|
||||
COMMAND_TEMPLATE,
|
||||
DEFAULT_CATEGORIES,
|
||||
COMMAND_VALIDATION
|
||||
DEFAULT_CATEGORIES
|
||||
} from '../../types/customCommands';
|
||||
|
||||
interface CustomCommandsSettingsProps {
|
||||
@@ -26,12 +23,9 @@ const ICON_MAP = {
|
||||
export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = () => {
|
||||
const [commands, setCommands] = useState<CustomCommand[]>([]);
|
||||
const [categories] = useState<CustomCommandCategory[]>(DEFAULT_CATEGORIES);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCommand, setEditingCommand] = useState<CustomCommand | null>(null);
|
||||
const [formData, setFormData] = useState(COMMAND_TEMPLATE);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Load commands from storage on mount
|
||||
useEffect(() => {
|
||||
@@ -67,48 +61,7 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Validate name
|
||||
if (!formData.name) {
|
||||
newErrors.name = 'Command name is required';
|
||||
} else if (formData.name.length < COMMAND_VALIDATION.name.minLength) {
|
||||
newErrors.name = `Name must be at least ${COMMAND_VALIDATION.name.minLength} characters`;
|
||||
} else if (!COMMAND_VALIDATION.name.pattern.test(formData.name)) {
|
||||
newErrors.name = 'Name must start with a letter and contain only letters and numbers';
|
||||
} else if (commands.some(cmd => cmd.name.toLowerCase() === formData.name.toLowerCase() && cmd.id !== editingCommand?.id)) {
|
||||
newErrors.name = 'A command with this name already exists';
|
||||
}
|
||||
|
||||
// Validate label
|
||||
if (!formData.label) {
|
||||
newErrors.label = 'Label is required';
|
||||
} else if (formData.label.length < COMMAND_VALIDATION.label.minLength) {
|
||||
newErrors.label = `Label must be at least ${COMMAND_VALIDATION.label.minLength} characters`;
|
||||
}
|
||||
|
||||
// Validate description
|
||||
if (!formData.description) {
|
||||
newErrors.description = 'Description is required';
|
||||
} else if (formData.description.length < COMMAND_VALIDATION.description.minLength) {
|
||||
newErrors.description = `Description must be at least ${COMMAND_VALIDATION.description.minLength} characters`;
|
||||
}
|
||||
|
||||
// Validate prompt
|
||||
if (!formData.prompt) {
|
||||
newErrors.prompt = 'Prompt is required';
|
||||
} else if (formData.prompt.length < COMMAND_VALIDATION.prompt.minLength) {
|
||||
newErrors.prompt = `Prompt must be at least ${COMMAND_VALIDATION.prompt.minLength} characters`;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
const handleModalSave = (command: CustomCommand) => {
|
||||
const now = new Date();
|
||||
let updatedCommands: CustomCommand[];
|
||||
|
||||
@@ -116,42 +69,30 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
|
||||
// Update existing command
|
||||
updatedCommands = commands.map(cmd =>
|
||||
cmd.id === editingCommand.id
|
||||
? { ...formData, id: editingCommand.id, createdAt: editingCommand.createdAt, updatedAt: now }
|
||||
? { ...command, id: editingCommand.id, createdAt: editingCommand.createdAt, updatedAt: now }
|
||||
: cmd
|
||||
);
|
||||
} else {
|
||||
// Create new command
|
||||
const newCommand: CustomCommand = {
|
||||
...formData,
|
||||
id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
updatedCommands = [...commands, newCommand];
|
||||
updatedCommands = [...commands, { ...command, createdAt: now, updatedAt: now }];
|
||||
}
|
||||
|
||||
saveCommands(updatedCommands);
|
||||
setIsEditing(false);
|
||||
setEditingCommand(null);
|
||||
setFormData(COMMAND_TEMPLATE);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleEdit = (command: CustomCommand) => {
|
||||
setEditingCommand(command);
|
||||
setFormData({
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
description: command.description,
|
||||
prompt: command.prompt,
|
||||
icon: command.icon || 'Zap',
|
||||
category: command.category || 'general',
|
||||
variables: command.variables || [],
|
||||
usageCount: command.usageCount,
|
||||
isFavorite: command.isFavorite,
|
||||
});
|
||||
setIsEditing(true);
|
||||
setErrors({});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingCommand(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingCommand(null);
|
||||
};
|
||||
|
||||
const handleDelete = (commandId: string) => {
|
||||
@@ -187,286 +128,133 @@ export const CustomCommandsSettings: React.FC<CustomCommandsSettingsProps> = ()
|
||||
cmd.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cmd.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || cmd.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditingCommand(null);
|
||||
setFormData(COMMAND_TEMPLATE);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search commands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md bg-background-default text-text-default"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload size={16} className="mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button onClick={() => setIsEditing(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
New Command
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commands List - Row Style */}
|
||||
{filteredCommands.map(command => (
|
||||
<div key={command.id} className="group hover:cursor-pointer text-sm">
|
||||
<div className="flex items-center justify-between text-text-default py-2 px-2 bg-background-default hover:bg-background-muted rounded-lg transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 bg-background-muted rounded-full flex items-center justify-center">
|
||||
{ICON_MAP[command.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-text-default font-medium">/{command.name}</h3>
|
||||
<p className="text-text-muted text-xs mt-[2px]">{command.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(command.id);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
{command.isFavorite ? (
|
||||
<Star size={12} className="text-yellow-500 fill-yellow-500" />
|
||||
) : (
|
||||
<StarOff size={12} className="text-text-muted" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(command);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
<Edit size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(command);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
<Copy size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(command.id);
|
||||
}}
|
||||
className="p-1 h-6 w-6 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-background-muted rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Zap size={16} className="text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-text-default mb-1">
|
||||
{searchQuery || selectedCategory !== 'all' ? 'No commands found' : 'No custom commands yet'}
|
||||
</h3>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
{searchQuery || selectedCategory !== 'all'
|
||||
? 'Try adjusting your search or category filter'
|
||||
: 'Create your first custom slash command to get started'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && selectedCategory === 'all' && (
|
||||
<Button onClick={() => setIsEditing(true)} size="sm">
|
||||
<Plus size={14} className="mr-2" />
|
||||
Create Command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Command Editor */
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-textStandard">
|
||||
{editingCommand ? 'Edit Command' : 'Create New Command'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editingCommand ? 'Update' : 'Create'} Command
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Command Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., document, review, explain"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className={errors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
|
||||
<p className="text-textSubtle text-xs mt-1">
|
||||
Users will type /{formData.name || 'name'} to use this command
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="label">Display Label *</Label>
|
||||
<Input
|
||||
id="label"
|
||||
placeholder="e.g., Create Document, Code Review"
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
className={errors.label ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.label && <p className="text-red-500 text-sm mt-1">{errors.label}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Brief description of what this command does"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className={errors.description ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.description && <p className="text-red-500 text-sm mt-1">{errors.description}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="icon">Icon</Label>
|
||||
<select
|
||||
id="icon"
|
||||
value={formData.icon}
|
||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-borderStandard rounded-md bg-background-default text-textStandard"
|
||||
>
|
||||
{Object.keys(ICON_MAP).map(iconName => (
|
||||
<option key={iconName} value={iconName}>{iconName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="prompt">Prompt Template *</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
placeholder="Enter the full prompt that will be sent to the AI when this command is used..."
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData({ ...formData, prompt: e.target.value })}
|
||||
rows={8}
|
||||
className={errors.prompt ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.prompt && <p className="text-red-500 text-sm mt-1">{errors.prompt}</p>}
|
||||
<p className="text-textSubtle text-xs mt-1">
|
||||
This is the actual prompt that will be sent to the AI. Users will see [{formData.label || 'Label'}] as a pill in their input.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="border border-borderStandard rounded-lg p-4 bg-bgSubtle">
|
||||
<h4 className="font-medium text-textStandard mb-2">Preview</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-textSubtle">User types:</span>
|
||||
<code className="bg-background-default px-2 py-1 rounded text-sm">/{formData.name || 'name'}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-textSubtle">User sees:</span>
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-bgProminent text-textProminentInverse border border-borderProminent rounded-full text-xs font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="relative">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full absolute inset-0" />
|
||||
<span className="relative text-white text-[8px] flex items-center justify-center w-3 h-3">
|
||||
{ICON_MAP[formData.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
|
||||
</span>
|
||||
</span>
|
||||
{formData.label || 'Label'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-textSubtle text-sm">
|
||||
<span>AI receives:</span>
|
||||
<div className="bg-background-default p-2 rounded mt-1 text-xs font-mono max-h-32 overflow-y-auto">
|
||||
{formData.prompt || 'Prompt template will appear here...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{/* Header Actions */}
|
||||
<div className="flex items-center justify-between gap-4 mb-4 px-2">
|
||||
<Input
|
||||
placeholder="Search commands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="p-2">
|
||||
<Upload size={16} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="p-2">
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} size="sm" className="p-2">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commands List - Row Style */}
|
||||
{filteredCommands.map(command => (
|
||||
<div key={command.id} className="group hover:cursor-pointer text-sm">
|
||||
<div className="flex items-center justify-between text-text-default py-2 px-2 bg-background-default hover:bg-background-muted rounded-lg transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center text-text-muted">
|
||||
{ICON_MAP[command.icon as keyof typeof ICON_MAP] || ICON_MAP.Zap}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-text-default font-medium">/{command.name}</h3>
|
||||
<p className="text-text-muted text-xs mt-[2px]">{command.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleFavorite(command.id);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
{command.isFavorite ? (
|
||||
<Star size={12} className="text-yellow-500 fill-yellow-500" />
|
||||
) : (
|
||||
<StarOff size={12} className="text-text-muted" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(command);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
<Edit size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicate(command);
|
||||
}}
|
||||
className="p-1 h-6 w-6"
|
||||
>
|
||||
<Copy size={12} className="text-text-muted hover:text-text-default" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(command.id);
|
||||
}}
|
||||
className="p-1 h-6 w-6 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredCommands.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-background-muted rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Zap size={16} className="text-text-muted" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-text-default mb-1">
|
||||
{searchQuery ? 'No commands found' : 'No custom commands yet'}
|
||||
</h3>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search query'
|
||||
: 'Create your first custom slash command to get started'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus size={14} className="mr-2" />
|
||||
Create Command
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<AddCustomCommandModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleModalSave}
|
||||
editingCommand={editingCommand}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ export default function ChatSettingsSection() {
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2 rounded-lg">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="">Custom Commands</CardTitle>
|
||||
<CardDescription>Create custom slash commands with rich prompts that expand when sent to the AI</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2">
|
||||
<CustomCommandsSettings />
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user