feat(a2ui): Implement A2UI backend with question handling and WebSocket support

- Added A2UITypes for defining question structures and answers.
- Created A2UIWebSocketHandler for managing WebSocket connections and message handling.
- Developed ask-question tool for interactive user questions via A2UI.
- Introduced platformUtils for platform detection and shell command handling.
- Centralized TypeScript types in index.ts for better organization.
- Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View File

@@ -0,0 +1,194 @@
// ========================================
// Event Group Component
// ========================================
// Groups hooks by trigger event type
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
ChevronDown,
ChevronUp,
Zap,
Wrench,
CheckCircle,
StopCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { HookCard, type HookCardData, type HookTriggerType } from './HookCard';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface EventGroupProps {
eventType: HookTriggerType;
hooks: HookCardData[];
onHookToggle: (hookName: string, enabled: boolean) => void;
onHookEdit: (hook: HookCardData) => void;
onHookDelete: (hookName: string) => void;
}
// ========== Helper Functions ==========
function getEventIcon(eventType: HookTriggerType) {
switch (eventType) {
case 'UserPromptSubmit':
return Zap;
case 'PreToolUse':
return Wrench;
case 'PostToolUse':
return CheckCircle;
case 'Stop':
return StopCircle;
}
}
function getEventColor(eventType: HookTriggerType): string {
switch (eventType) {
case 'UserPromptSubmit':
return 'text-amber-500 bg-amber-500/10';
case 'PreToolUse':
return 'text-blue-500 bg-blue-500/10';
case 'PostToolUse':
return 'text-green-500 bg-green-500/10';
case 'Stop':
return 'text-red-500 bg-red-500/10';
}
}
// ========== Component ==========
export function EventGroup({
eventType,
hooks,
onHookToggle,
onHookEdit,
onHookDelete,
}: EventGroupProps) {
const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = useState(true);
const [expandedHooks, setExpandedHooks] = useState<Set<string>>(new Set());
const Icon = getEventIcon(eventType);
const iconColorClass = getEventColor(eventType);
const enabledCount = hooks.filter((h) => h.enabled).length;
const totalCount = hooks.length;
const handleToggleExpand = () => {
setIsExpanded(!isExpanded);
};
const handleToggleHookExpand = (hookName: string) => {
setExpandedHooks((prev) => {
const next = new Set(prev);
if (next.has(hookName)) {
next.delete(hookName);
} else {
next.add(hookName);
}
return next;
});
};
const handleExpandAll = () => {
setExpandedHooks(new Set(hooks.map((h) => h.name)));
};
const handleCollapseAll = () => {
setExpandedHooks(new Set());
};
return (
<Card className="overflow-hidden">
{/* Event Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors border-b border-border"
onClick={handleToggleExpand}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn('p-2 rounded-lg', iconColorClass)}>
<Icon className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
{formatMessage({ id: `cliHooks.trigger.${eventType}` })}
</h3>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'cliHooks.stats.count' }, {
enabled: enabledCount,
total: totalCount
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{totalCount}
</Badge>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Hooks List */}
{isExpanded && (
<div className="p-4 space-y-3 bg-muted/10">
{totalCount === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{formatMessage({ id: 'cliHooks.empty.noHooksInEvent' })}</p>
</div>
) : (
<>
{/* Expand/Collapse All */}
{totalCount > 1 && (
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleExpandAll}
>
{formatMessage({ id: 'cliHooks.actions.expandAll' })}
</Button>
<span className="text-muted-foreground text-xs">/</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleCollapseAll}
>
{formatMessage({ id: 'cliHooks.actions.collapseAll' })}
</Button>
</div>
)}
{/* Hook Cards */}
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.name}
hook={hook}
isExpanded={expandedHooks.has(hook.name)}
onToggleExpand={() => handleToggleHookExpand(hook.name)}
onToggle={onHookToggle}
onEdit={onHookEdit}
onDelete={onHookDelete}
/>
))}
</div>
</>
)}
</div>
)}
</Card>
);
}
export default EventGroup;

View File

@@ -0,0 +1,238 @@
// ========================================
// Hook Card Component
// ========================================
// Individual hook display card with actions
import { useIntl } from 'react-intl';
import {
GitFork,
Power,
PowerOff,
Edit,
Trash2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export type HookTriggerType = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
export interface HookCardData {
name: string;
description?: string;
enabled: boolean;
trigger: HookTriggerType;
matcher?: string;
command?: string;
script?: string;
}
export interface HookCardProps {
hook: HookCardData;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: (hookName: string, enabled: boolean) => void;
onEdit: (hook: HookCardData) => void;
onDelete: (hookName: string) => void;
}
// ========== Helper Functions ==========
function getTriggerIcon(trigger: HookTriggerType) {
switch (trigger) {
case 'UserPromptSubmit':
return '⚡';
case 'PreToolUse':
return '🔧';
case 'PostToolUse':
return '✅';
case 'Stop':
return '🛑';
default:
return '📌';
}
}
function getTriggerVariant(trigger: HookTriggerType): 'default' | 'secondary' | 'outline' {
switch (trigger) {
case 'UserPromptSubmit':
return 'default';
case 'PreToolUse':
return 'secondary';
case 'PostToolUse':
return 'outline';
case 'Stop':
return 'secondary';
default:
return 'outline';
}
}
// ========== Component ==========
export function HookCard({
hook,
isExpanded,
onToggleExpand,
onToggle,
onEdit,
onDelete,
}: HookCardProps) {
const { formatMessage } = useIntl();
const handleToggle = () => {
onToggle(hook.name, !hook.enabled);
};
const handleEdit = () => {
onEdit(hook);
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'cliHooks.actions.deleteConfirm' }, { hookName: hook.name }))) {
onDelete(hook.name);
}
};
return (
<Card className={cn('overflow-hidden', !hook.enabled && 'opacity-60')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
hook.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<GitFork className={cn(
'w-4 h-4',
hook.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{hook.name}
</span>
<Badge
variant={getTriggerVariant(hook.trigger)}
className="text-xs flex-shrink-0"
>
<span className="mr-1">{getTriggerIcon(hook.trigger)}</span>
{formatMessage({ id: `cliHooks.trigger.${hook.trigger}` })}
</Badge>
<Badge
variant={hook.enabled ? 'default' : 'secondary'}
className="text-xs flex-shrink-0"
>
{hook.enabled
? formatMessage({ id: 'common.status.enabled' })
: formatMessage({ id: 'common.status.disabled' })
}
</Badge>
</div>
{hook.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{hook.description}
</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleToggle}
title={hook.enabled
? formatMessage({ id: 'cliHooks.actions.disable' })
: formatMessage({ id: 'cliHooks.actions.enable' })
}
>
{hook.enabled ? (
<Power className="w-4 h-4 text-success" />
) : (
<PowerOff className="w-4 h-4 text-muted-foreground" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleEdit}
title={formatMessage({ id: 'common.actions.edit' })}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
title={formatMessage({ id: 'common.actions.delete' })}
>
<Trash2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onToggleExpand}
title={isExpanded
? formatMessage({ id: 'cliHooks.actions.collapse' })
: formatMessage({ id: 'cliHooks.actions.expand' })
}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border bg-muted/30 p-4 space-y-3">
{hook.description && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.description' })}
</label>
<p className="text-sm text-foreground mt-1">{hook.description}</p>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.matcher' })}
</label>
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded">
{hook.matcher || formatMessage({ id: 'cliHooks.allTools' })}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'cliHooks.form.command' })}
</label>
<p className="text-sm text-foreground mt-1 font-mono bg-muted px-2 py-1 rounded break-all max-h-32 overflow-y-auto">
{hook.command || hook.script || 'N/A'}
</p>
</div>
</div>
)}
</Card>
);
}
export default HookCard;

View File

@@ -0,0 +1,289 @@
// ========================================
// Hook Form Dialog Component
// ========================================
// Dialog for creating and editing hooks
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import type { HookCardData, HookTriggerType } from './HookCard';
// ========== Types ==========
export type HookFormMode = 'create' | 'edit';
export interface HookFormData {
name: string;
description: string;
trigger: HookTriggerType;
matcher: string;
command: string;
}
export interface HookFormDialogProps {
mode: HookFormMode;
hook?: HookCardData;
open: boolean;
onClose: () => void;
onSave: (data: HookFormData) => Promise<void>;
}
// ========== Helper: Form Validation ==========
interface FormErrors {
name?: string;
trigger?: string;
command?: string;
}
function validateForm(data: HookFormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) {
errors.name = 'validation.nameRequired';
} else if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) {
errors.name = 'validation.nameInvalid';
}
if (!data.trigger) {
errors.trigger = 'validation.triggerRequired';
}
if (!data.command.trim()) {
errors.command = 'validation.commandRequired';
}
return errors;
}
// ========== Component ==========
export function HookFormDialog({
mode,
hook,
open,
onClose,
onSave,
}: HookFormDialogProps) {
const { formatMessage } = useIntl();
const [formData, setFormData] = useState<HookFormData>({
name: '',
description: '',
trigger: 'PostToolUse',
matcher: '',
command: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when dialog opens or hook changes
useEffect(() => {
if (open) {
if (mode === 'edit' && hook) {
setFormData({
name: hook.name,
description: hook.description || '',
trigger: hook.trigger,
matcher: hook.matcher || '',
command: hook.command || hook.script || '',
});
} else {
setFormData({
name: '',
description: '',
trigger: 'PostToolUse',
matcher: '',
command: '',
});
}
setErrors({});
}
}, [open, mode, hook]);
const handleFieldChange = (
field: keyof HookFormData,
value: string
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error for this field when user starts typing
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSubmit = async () => {
// Validate form
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
try {
await onSave(formData);
onClose();
} catch (error) {
console.error('Failed to save hook:', error);
} finally {
setIsSubmitting(false);
}
};
const TRIGGER_OPTIONS: { value: HookTriggerType; label: string }[] = [
{ value: 'UserPromptSubmit', label: 'cliHooks.trigger.UserPromptSubmit' },
{ value: 'PreToolUse', label: 'cliHooks.trigger.PreToolUse' },
{ value: 'PostToolUse', label: 'cliHooks.trigger.PostToolUse' },
{ value: 'Stop', label: 'cliHooks.trigger.Stop' },
];
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{mode === 'create'
? formatMessage({ id: 'cliHooks.dialog.createTitle' })
: formatMessage({ id: 'cliHooks.dialog.editTitle' }, { hookName: hook?.name })
}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name */}
<div>
<label htmlFor="hook-name" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.name' })} *
</label>
<Input
id="hook-name"
value={formData.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.namePlaceholder' })}
className="mt-1"
error={!!errors.name}
/>
{errors.name && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.name}` })}
</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="hook-description" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.description' })}
</label>
<Textarea
id="hook-description"
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.descriptionPlaceholder' })}
className="mt-1"
rows={2}
/>
</div>
{/* Trigger */}
<div>
<label htmlFor="hook-trigger" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.trigger' })} *
</label>
<Select
value={formData.trigger}
onValueChange={(value) => handleFieldChange('trigger', value as HookTriggerType)}
>
<SelectTrigger className="mt-1" id="hook-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.label })}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.trigger && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.trigger}` })}
</p>
)}
</div>
{/* Matcher */}
<div>
<label htmlFor="hook-matcher" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.matcher' })}
</label>
<Input
id="hook-matcher"
value={formData.matcher}
onChange={(e) => handleFieldChange('matcher', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.matcherPlaceholder' })}
className="mt-1 font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.form.matcherHelp' })}
</p>
</div>
{/* Command */}
<div>
<label htmlFor="hook-command" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.form.command' })} *
</label>
<Textarea
id="hook-command"
value={formData.command}
onChange={(e) => handleFieldChange('command', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.form.commandPlaceholder' })}
className="mt-1 font-mono text-sm"
rows={4}
error={!!errors.command}
/>
{errors.command && (
<p className="text-xs text-destructive mt-1">
{formatMessage({ id: `cliHooks.${errors.command}` })}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.form.commandHelp' })}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? formatMessage({ id: 'common.actions.saving' })
: formatMessage({ id: 'common.actions.save' })
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default HookFormDialog;

View File

@@ -0,0 +1,268 @@
// ========================================
// Hook Quick Templates Component
// ========================================
// Predefined hook templates for quick installation
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Bell,
Database,
Wrench,
Check,
Zap,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* Template category type
*/
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
/**
* Hook template definition
*/
export interface HookTemplate {
id: string;
name: string;
description: string;
category: TemplateCategory;
trigger: 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'Stop';
command: string;
args?: string[];
matcher?: string;
}
/**
* Component props
*/
export interface HookQuickTemplatesProps {
/** Callback when install button is clicked */
onInstallTemplate: (templateId: string) => Promise<void>;
/** Array of installed template IDs */
installedTemplates: string[];
/** Optional loading state */
isLoading?: boolean;
}
// ========== Hook Templates ==========
/**
* Predefined hook templates for quick installation
*/
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
{
id: 'ccw-notify',
name: 'CCW Dashboard Notify',
description: 'Send notifications to CCW dashboard when files are written',
category: 'notification',
trigger: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: [
'-c',
'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'
]
},
{
id: 'codexlens-update',
name: 'CodexLens Auto-Update',
description: 'Update CodexLens index when files are written or edited',
category: 'indexing',
trigger: 'Stop',
command: 'bash',
args: [
'-c',
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'
]
},
{
id: 'git-add',
name: 'Auto Git Stage',
description: 'Automatically stage written files to git',
category: 'automation',
trigger: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: [
'-c',
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'
]
},
{
id: 'lint-check',
name: 'Auto ESLint',
description: 'Run ESLint on JavaScript/TypeScript files after write',
category: 'automation',
trigger: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: [
'-c',
'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'
]
},
{
id: 'log-tool',
name: 'Tool Usage Logger',
description: 'Log all tool executions to a file for audit trail',
category: 'automation',
trigger: 'PostToolUse',
command: 'bash',
args: [
'-c',
'mkdir -p "$HOME/.claude"; INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty" 2>/dev/null); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty" 2>/dev/null); echo "[$(date)] Tool: $TOOL, File: $FILE" >> "$HOME/.claude/tool-usage.log"'
]
}
] as const;
// ========== Category Icons ==========
const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: string }> = {
notification: { icon: Bell, color: 'text-blue-500' },
indexing: { icon: Database, color: 'text-purple-500' },
automation: { icon: Wrench, color: 'text-orange-500' }
};
// ========== Category Names ==========
function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<typeof useIntl>['formatMessage']): string {
const names: Record<TemplateCategory, string> = {
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
};
return names[category];
}
// ========== Main Component ==========
/**
* HookQuickTemplates - Display predefined hook templates for quick installation
*/
export function HookQuickTemplates({
onInstallTemplate,
installedTemplates,
isLoading = false
}: HookQuickTemplatesProps) {
const { formatMessage } = useIntl();
// Group templates by category
const templatesByCategory = useMemo(() => {
return HOOK_TEMPLATES.reduce((acc, template) => {
if (!acc[template.category]) {
acc[template.category] = [];
}
acc[template.category].push(template);
return acc;
}, {} as Record<TemplateCategory, HookTemplate[]>);
}, []);
// Define category order
const categoryOrder: TemplateCategory[] = ['notification', 'indexing', 'automation'];
const handleInstall = async (templateId: string) => {
await onInstallTemplate(templateId);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'cliHooks.templates.title' })}
</h2>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.templates.description' })}
</p>
</div>
</div>
{/* Categories */}
{categoryOrder.map((category) => {
const templates = templatesByCategory[category];
if (!templates || templates.length === 0) return null;
const { icon: CategoryIcon, color } = CATEGORY_ICONS[category];
return (
<div key={category} className="space-y-3">
{/* Category Header */}
<div className="flex items-center gap-2">
<CategoryIcon className={cn('w-4 h-4', color)} />
<h3 className="text-sm font-medium text-foreground">
{getCategoryName(category, formatMessage)}
</h3>
<Badge variant="outline" className="text-xs">
{templates.length}
</Badge>
</div>
{/* Template Cards */}
<div className="grid grid-cols-1 gap-3">
{templates.map((template) => {
const isInstalled = installedTemplates.includes(template.id);
const isInstalling = isLoading && !isInstalled;
return (
<Card key={template.id} className="p-4">
<div className="flex items-start justify-between gap-4">
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
</h4>
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: `cliHooks.trigger.${template.trigger}` })}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
</p>
{template.matcher && (
<p className="text-xs text-muted-foreground mt-1">
<span className="font-mono bg-muted px-1 rounded">
{template.matcher}
</span>
</p>
)}
</div>
{/* Install Button */}
<Button
size="sm"
variant={isInstalled ? 'outline' : 'default'}
disabled={isInstalled || isInstalling}
onClick={() => handleInstall(template.id)}
className="shrink-0"
>
{isInstalled ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'cliHooks.templates.actions.installed' })}
</>
) : (
formatMessage({ id: 'cliHooks.templates.actions.install' })
)}
</Button>
</div>
</Card>
);
})}
</div>
</div>
);
})}
</div>
);
}
export default HookQuickTemplates;

View File

@@ -0,0 +1,842 @@
// ========================================
// Hook Wizard Component
// ========================================
// Multi-step wizard for creating common hook patterns
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
ChevronLeft,
ChevronRight,
X,
Brain,
Shield,
Sparkles,
CheckCircle,
AlertTriangle,
Plus,
Trash2,
} from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { fetchSkills, type Skill, createHook } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
detect,
getShell,
getShellName,
checkCompatibility,
getPlatformName,
adjustCommandForPlatform,
DEFAULT_PLATFORM_REQUIREMENTS,
type Platform,
} from '@/utils/platformUtils';
// ========== Types ==========
/**
* Supported wizard types
*/
export type WizardType = 'memory-update' | 'danger-protection' | 'skill-context';
/**
* Wizard step number
*/
type WizardStep = 1 | 2 | 3;
/**
* Component props
*/
export interface HookWizardProps {
/** Type of wizard to launch */
wizardType: WizardType;
/** Whether the dialog is open */
open: boolean;
/** Callback when dialog is closed */
onClose: () => void;
/** Callback when wizard completes with hook configuration */
onComplete: (hookConfig: {
name: string;
description: string;
trigger: string;
matcher?: string;
command: string;
}) => Promise<void>;
}
/**
* Memory update wizard configuration
*/
interface MemoryUpdateConfig {
claudePath: string;
updateFrequency: 'session-end' | 'hourly' | 'daily';
sections: string[];
}
/**
* Danger protection wizard configuration
*/
interface DangerProtectionConfig {
keywords: string;
confirmationMessage: string;
allowBypass: boolean;
}
/**
* Skill context wizard configuration
*/
interface SkillContextConfig {
keywordSkillPairs: Array<{ keyword: string; skill: string }>;
priority: 'high' | 'medium' | 'low';
}
/**
* Wizard configuration union type
*/
type WizardConfig = MemoryUpdateConfig | DangerProtectionConfig | SkillContextConfig;
// ========== Wizard Definitions ==========
/**
* Wizard metadata for each type
*/
const WIZARD_METADATA = {
'memory-update': {
title: 'cliHooks.wizards.memoryUpdate.title',
description: 'cliHooks.wizards.memoryUpdate.description',
icon: Brain,
trigger: 'Stop' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['memory-update'],
},
'danger-protection': {
title: 'cliHooks.wizards.dangerProtection.title',
description: 'cliHooks.wizards.dangerProtection.description',
icon: Shield,
trigger: 'UserPromptSubmit' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['danger-protection'],
},
'skill-context': {
title: 'cliHooks.wizards.skillContext.title',
description: 'cliHooks.wizards.skillContext.description',
icon: Sparkles,
trigger: 'UserPromptSubmit' as const,
platformRequirements: DEFAULT_PLATFORM_REQUIREMENTS['skill-context'],
},
} as const;
// ========== Helper Functions ==========
/**
* Get wizard icon component
*/
function getWizardIcon(type: WizardType) {
return WIZARD_METADATA[type].icon;
}
// ========== Main Component ==========
export function HookWizard({
wizardType,
open,
onClose,
onComplete,
}: HookWizardProps) {
const { formatMessage } = useIntl();
const [currentStep, setCurrentStep] = useState<WizardStep>(1);
const [detectedPlatform, setDetectedPlatform] = useState<Platform>('linux');
// Fetch available skills for skill-context wizard
const { data: skillsData, isLoading: skillsLoading } = useQuery({
queryKey: ['skills'],
queryFn: fetchSkills,
enabled: open && wizardType === 'skill-context',
});
// Mutation for creating hook
const createMutation = useMutation({
mutationFn: createHook,
onSuccess: () => {
onClose();
setCurrentStep(1);
},
});
// Detect platform on mount
useEffect(() => {
if (open) {
setDetectedPlatform(detect());
}
}, [open]);
// Wizard configuration state
const [memoryConfig, setMemoryConfig] = useState<MemoryUpdateConfig>({
claudePath: '.claude/CLAUDE.md',
updateFrequency: 'session-end',
sections: ['all'],
});
const [dangerConfig, setDangerConfig] = useState<DangerProtectionConfig>({
keywords: 'delete\nrm\nformat\ndrop\ntruncate\nshutdown',
confirmationMessage: 'Are you sure you want to perform this action: {action}?',
allowBypass: true,
});
const [skillConfig, setSkillConfig] = useState<SkillContextConfig>({
keywordSkillPairs: [{ keyword: '', skill: '' }],
priority: 'medium',
});
// Check platform compatibility
const wizardMetadata = WIZARD_METADATA[wizardType];
const compatibilityCheck = checkCompatibility(
wizardMetadata.platformRequirements,
detectedPlatform
);
// Handlers
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep((prev) => (prev + 1) as WizardStep);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep((prev) => (prev - 1) as WizardStep);
}
};
const handleClose = () => {
setCurrentStep(1);
onClose();
};
const handleComplete = async () => {
let hookConfig: {
name: string;
description: string;
trigger: string;
matcher?: string;
command: string;
};
const wizardName = formatMessage({ id: WIZARD_METADATA[wizardType].title });
switch (wizardType) {
case 'memory-update':
hookConfig = {
name: `memory-update-${Date.now()}`,
description: `${wizardName}: Update ${memoryConfig.claudePath} on ${memoryConfig.updateFrequency}`,
trigger: wizardMetadata.trigger,
command: buildMemoryUpdateCommand(memoryConfig, detectedPlatform),
};
break;
case 'danger-protection':
hookConfig = {
name: `danger-protection-${Date.now()}`,
description: `${wizardName}: Confirm dangerous operations`,
trigger: wizardMetadata.trigger,
matcher: buildDangerMatcher(dangerConfig),
command: buildDangerProtectionCommand(dangerConfig, detectedPlatform),
};
break;
case 'skill-context':
hookConfig = {
name: `skill-context-${Date.now()}`,
description: `${wizardName}: Load SKILL based on keywords`,
trigger: wizardMetadata.trigger,
matcher: buildSkillMatcher(skillConfig),
command: buildSkillContextCommand(skillConfig, detectedPlatform),
};
break;
default:
return;
}
await createMutation.mutateAsync(hookConfig);
};
// Step renderers
const renderStep1 = () => {
const WizardIcon = getWizardIcon(wizardType);
return (
<div className="space-y-4">
{/* Introduction */}
<div className="flex items-center gap-3 pb-4 border-b">
<div className="p-3 rounded-lg bg-primary/10">
<WizardIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].description })}
</p>
</div>
</div>
{/* Platform Detection */}
<Card className="p-4">
<div className="flex items-center gap-3">
<CheckCircle className={cn(
'w-5 h-5',
compatibilityCheck.compatible ? 'text-green-500' : 'text-destructive'
)} />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.platform.detected' })}
</p>
<p className="text-xs text-muted-foreground">
{getPlatformName(detectedPlatform)} ({getShellName(getShell(detectedPlatform))})
</p>
</div>
<Badge variant={compatibilityCheck.compatible ? 'default' : 'destructive'}>
{compatibilityCheck.compatible
? formatMessage({ id: 'cliHooks.wizards.platform.compatible' })
: formatMessage({ id: 'cliHooks.wizards.platform.incompatible' })
}
</Badge>
</div>
{/* Compatibility Issues */}
{!compatibilityCheck.compatible && compatibilityCheck.issues.length > 0 && (
<div className="mt-3 flex items-start gap-2 p-3 bg-destructive/10 rounded-lg">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityError' })}
</p>
<ul className="mt-1 space-y-1">
{compatibilityCheck.issues.map((issue, i) => (
<li key={i} className="text-xs text-destructive/80">
{issue}
</li>
))}
</ul>
</div>
</div>
)}
{/* Compatibility Warnings */}
{compatibilityCheck.warnings.length > 0 && (
<div className="mt-3 flex items-start gap-2 p-3 bg-yellow-500/10 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-600">
{formatMessage({ id: 'cliHooks.wizards.platform.compatibilityWarning' })}
</p>
<ul className="mt-1 space-y-1">
{compatibilityCheck.warnings.map((warning, i) => (
<li key={i} className="text-xs text-yellow-600/80">
{warning}
</li>
))}
</ul>
</div>
</div>
)}
</Card>
{/* Trigger Event */}
<Card className="p-4">
<p className="text-sm text-muted-foreground mb-2">
{formatMessage({ id: 'cliHooks.wizards.steps.triggerEvent' })}
</p>
<Badge variant="secondary">
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
</Badge>
</Card>
</div>
);
};
const renderStep2 = () => {
switch (wizardType) {
case 'memory-update':
return renderMemoryUpdateConfig();
case 'danger-protection':
return renderDangerProtectionConfig();
case 'skill-context':
return renderSkillContextConfig();
default:
return null;
}
};
const renderStep3 = () => {
return (
<div className="space-y-4">
<div className="text-center pb-4 border-b">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.description' })}
</p>
</div>
{/* Summary */}
<Card className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.hookType' })}
</span>
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: WIZARD_METADATA[wizardType].title })}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.trigger' })}
</span>
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: `cliHooks.trigger.${wizardMetadata.trigger}` })}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.steps.review.platform' })}
</span>
<span className="text-sm text-foreground">
{getPlatformName(detectedPlatform)}
</span>
</div>
{/* Configuration Summary */}
{renderConfigSummary()}
</Card>
{/* Command Preview */}
<Card className="p-4">
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'cliHooks.wizards.steps.review.commandPreview' })}
</p>
<pre className="text-xs font-mono bg-muted p-3 rounded-lg overflow-x-auto">
{getPreviewCommand()}
</pre>
</Card>
</div>
);
};
// Configuration renderers
const renderMemoryUpdateConfig = () => (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
</label>
<Input
value={memoryConfig.claudePath}
onChange={(e) => setMemoryConfig({ ...memoryConfig, claudePath: e.target.value })}
placeholder=".claude/CLAUDE.md"
className="mt-1 font-mono"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
</label>
<Select
value={memoryConfig.updateFrequency}
onValueChange={(value: any) => setMemoryConfig({ ...memoryConfig, updateFrequency: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="session-end">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.sessionEnd' })}
</SelectItem>
<SelectItem value="hourly">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.hourly' })}
</SelectItem>
<SelectItem value="daily">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.frequency.daily' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
const renderDangerProtectionConfig = () => (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}
</label>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywordsHelp' })}
</p>
<Textarea
value={dangerConfig.keywords}
onChange={(e) => setDangerConfig({ ...dangerConfig, keywords: e.target.value })}
placeholder="delete\nrm\nformat"
className="mt-1 font-mono text-sm"
rows={5}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.confirmationMessage' })}
</label>
<Input
value={dangerConfig.confirmationMessage}
onChange={(e) => setDangerConfig({ ...dangerConfig, confirmationMessage: e.target.value })}
placeholder="Are you sure you want to {action}?"
className="mt-1"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="allow-bypass"
checked={dangerConfig.allowBypass}
onChange={(e) => setDangerConfig({ ...dangerConfig, allowBypass: e.target.checked })}
className="rounded"
/>
<label htmlFor="allow-bypass" className="text-sm text-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
</label>
</div>
</div>
);
const renderSkillContextConfig = () => {
const skills = skillsData?.skills ?? [];
const addPair = () => {
setSkillConfig({
...skillConfig,
keywordSkillPairs: [...skillConfig.keywordSkillPairs, { keyword: '', skill: '' }],
});
};
const removePair = (index: number) => {
setSkillConfig({
...skillConfig,
keywordSkillPairs: skillConfig.keywordSkillPairs.filter((_, i) => i !== index),
});
};
const updatePair = (index: number, field: 'keyword' | 'skill', value: string) => {
const newPairs = [...skillConfig.keywordSkillPairs];
newPairs[index][field] = value;
setSkillConfig({ ...skillConfig, keywordSkillPairs: newPairs });
};
return (
<div className="space-y-4">
{skillsLoading ? (
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.loadingSkills' })}
</p>
) : (
<div className="space-y-3">
{skillConfig.keywordSkillPairs.map((pair, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={pair.keyword}
onChange={(e) => updatePair(index, 'keyword', e.target.value)}
placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.keywordPlaceholder' })}
className="flex-1"
/>
<Select value={pair.skill} onValueChange={(value) => updatePair(index, 'skill', value)}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={formatMessage({ id: 'cliHooks.wizards.skillContext.selectSkill' })} />
</SelectTrigger>
<SelectContent>
{skills.map((skill) => (
<SelectItem key={skill.name} value={skill.name}>
{skill.name}
</SelectItem>
))}
</SelectContent>
</Select>
{skillConfig.keywordSkillPairs.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={() => removePair(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
))}
<Button variant="outline" size="sm" onClick={addPair} className="w-full">
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliHooks.wizards.skillContext.addPair' })}
</Button>
</div>
)}
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
</label>
<Select
value={skillConfig.priority}
onValueChange={(value: any) => setSkillConfig({ ...skillConfig, priority: value })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityHigh' })}
</SelectItem>
<SelectItem value="medium">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityMedium' })}
</SelectItem>
<SelectItem value="low">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priorityLow' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};
// Config summary for review step
const renderConfigSummary = () => {
switch (wizardType) {
case 'memory-update':
return (
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.claudePath' })}
</span>
<span className="font-mono">{memoryConfig.claudePath}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.memoryUpdate.updateFrequency' })}
</span>
<span>
{formatMessage({ id: `cliHooks.wizards.memoryUpdate.frequency.${memoryConfig.updateFrequency}` })}
</span>
</div>
</div>
);
case 'danger-protection':
return (
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.keywords' })}:
</span>
<div className="mt-1 flex flex-wrap gap-1">
{dangerConfig.keywords.split('\n').filter(Boolean).map((kw, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{kw.trim()}
</Badge>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.dangerProtection.allowBypass' })}
</span>
<span>{dangerConfig.allowBypass ? 'Yes' : 'No'}</span>
</div>
</div>
);
case 'skill-context':
return (
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.keywordMappings' })}:
</span>
<div className="mt-1 space-y-1">
{skillConfig.keywordSkillPairs
.filter((p) => p.keyword && p.skill)
.map((pair, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<Badge variant="outline">{pair.keyword}</Badge>
<span className="text-muted-foreground">{'->'}</span>
<Badge variant="secondary">{pair.skill}</Badge>
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'cliHooks.wizards.skillContext.priority' })}
</span>
<span>
{formatMessage({ id: `cliHooks.wizards.skillContext.priority${skillConfig.priority.charAt(0).toUpperCase()}${skillConfig.priority.slice(1)}` })}
</span>
</div>
</div>
);
default:
return null;
}
};
// Get command preview for review step
const getPreviewCommand = (): string => {
switch (wizardType) {
case 'memory-update':
return buildMemoryUpdateCommand(memoryConfig, detectedPlatform);
case 'danger-protection':
return buildDangerProtectionCommand(dangerConfig, detectedPlatform);
case 'skill-context':
return buildSkillContextCommand(skillConfig, detectedPlatform);
default:
return '';
}
};
// Navigation buttons
const renderNavigation = () => (
<DialogFooter className="gap-2">
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrevious} disabled={createMutation.isPending}>
<ChevronLeft className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliHooks.wizards.navigation.previous' })}
</Button>
)}
{currentStep < 3 ? (
<Button onClick={handleNext} disabled={!compatibilityCheck.compatible}>
{formatMessage({ id: 'cliHooks.wizards.navigation.next' })}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : (
<Button onClick={handleComplete} disabled={createMutation.isPending}>
{createMutation.isPending
? formatMessage({ id: 'cliHooks.wizards.navigation.creating' })
: formatMessage({ id: 'cliHooks.wizards.navigation.create' })
}
</Button>
)}
<Button variant="ghost" onClick={handleClose} disabled={createMutation.isPending}>
<X className="w-4 h-4" />
</Button>
</DialogFooter>
);
// Step indicator
const renderStepIndicator = () => (
<div className="flex items-center justify-center gap-2 pb-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
currentStep === step
? 'bg-primary text-primary-foreground'
: currentStep > step
? 'bg-green-500 text-white'
: 'bg-muted text-muted-foreground'
)}
>
{currentStep > step ? <CheckCircle className="w-4 h-4" /> : step}
</div>
{step < 3 && (
<div
className={cn(
'w-8 h-0.5 mx-1',
currentStep > step ? 'bg-green-500' : 'bg-muted'
)}
/>
)}
</div>
))}
</div>
);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{formatMessage({ id: 'cliHooks.wizards.title' })}
</DialogTitle>
</DialogHeader>
{renderStepIndicator()}
<div className="min-h-[300px]">
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
</div>
{renderNavigation()}
</DialogContent>
</Dialog>
);
}
export default HookWizard;
// ========== Command Builders ==========
function buildMemoryUpdateCommand(config: MemoryUpdateConfig, platform: Platform): string {
const shellCmd = getShellCommand(getShell(platform));
const command = `echo "Updating ${config.claudePath} at ${config.updateFrequency}"`;
return JSON.stringify([...shellCmd, command]);
}
function buildDangerMatcher(config: DangerProtectionConfig): string {
const keywords = config.keywords.split('\n').filter(Boolean).join('|');
return `(${keywords})`;
}
function buildDangerProtectionCommand(config: DangerProtectionConfig, platform: Platform): string {
const shellCmd = getShellCommand(getShell(platform));
const command = `echo "Checking for dangerous operations: ${config.keywords.split('\n').filter(Boolean).join(', ')}"`;
return JSON.stringify([...shellCmd, command]);
}
function buildSkillMatcher(config: SkillContextConfig): string {
const keywords = config.keywordSkillPairs
.filter((p) => p.keyword)
.map((p) => p.keyword)
.join('|');
return `(${keywords})`;
}
function buildSkillContextCommand(config: SkillContextConfig, platform: Platform): string {
const pairs = config.keywordSkillPairs.filter((p) => p.keyword && p.skill);
const command = `echo "Loading SKILL based on keywords: ${pairs.map((p) => p.keyword).join(', ')}"`;
return JSON.stringify([...getShellCommand(getShell(platform)), command]);
}

View File

@@ -0,0 +1,19 @@
// ========================================
// Hook Components Barrel Export
// ========================================
export { HookCard } from './HookCard';
export type { HookCardProps, HookCardData, HookTriggerType } from './HookCard';
export { EventGroup } from './EventGroup';
export type { EventGroupProps } from './EventGroup';
export { HookFormDialog } from './HookFormDialog';
export type { HookFormDialogProps, HookFormMode, HookFormData } from './HookFormDialog';
export { HookQuickTemplates } from './HookQuickTemplates';
export type { HookQuickTemplatesProps, HookTemplate, TemplateCategory } from './HookQuickTemplates';
export { HOOK_TEMPLATES } from './HookQuickTemplates';
export { HookWizard } from './HookWizard';
export type { HookWizardProps, WizardType } from './HookWizard';

View File

@@ -8,6 +8,10 @@ import { cn } from '@/lib/utils';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { MainContent } from './MainContent';
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
import { NotificationPanel } from '@/components/notification';
import { useNotificationStore } from '@/stores';
import { useWebSocketNotifications } from '@/hooks';
export interface AppShellProps {
/** Initial sidebar collapsed state */
@@ -44,6 +48,23 @@ export function AppShell({
// Mobile sidebar open state
const [mobileOpen, setMobileOpen] = useState(false);
// CLI Monitor open state
const [isCliMonitorOpen, setIsCliMonitorOpen] = useState(false);
// Notification panel store integration
const isNotificationPanelVisible = useNotificationStore((state) => state.isPanelVisible);
const loadPersistentNotifications = useNotificationStore(
(state) => state.loadPersistentNotifications
);
// Initialize WebSocket notifications handler
useWebSocketNotifications();
// Load persistent notifications from localStorage on mount
useEffect(() => {
loadPersistentNotifications();
}, [loadPersistentNotifications]);
// Persist sidebar state
useEffect(() => {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(sidebarCollapsed));
@@ -73,6 +94,18 @@ export function AppShell({
setSidebarCollapsed(collapsed);
}, []);
const handleCliMonitorClick = useCallback(() => {
setIsCliMonitorOpen(true);
}, []);
const handleCliMonitorClose = useCallback(() => {
setIsCliMonitorOpen(false);
}, []);
const handleNotificationPanelClose = useCallback(() => {
useNotificationStore.getState().setPanelVisible(false);
}, []);
return (
<div className="flex flex-col min-h-screen bg-background">
{/* Header - fixed at top */}
@@ -81,6 +114,7 @@ export function AppShell({
projectPath={projectPath}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
onCliMonitorClick={handleCliMonitorClick}
/>
{/* Main layout - sidebar + content */}
@@ -97,13 +131,25 @@ export function AppShell({
<MainContent
className={cn(
'transition-all duration-300',
// Adjust padding on mobile when sidebar is hidden
'md:ml-0'
// Add left margin on desktop to account for fixed sidebar
sidebarCollapsed ? 'md:ml-16' : 'md:ml-64'
)}
>
{children}
</MainContent>
</div>
{/* CLI Stream Monitor - Global Drawer */}
<CliStreamMonitor
isOpen={isCliMonitorOpen}
onClose={handleCliMonitorClose}
/>
{/* Notification Panel - Global Drawer */}
<NotificationPanel
isOpen={isNotificationPanelVisible}
onClose={handleNotificationPanelClose}
/>
</div>
);
}

View File

@@ -15,11 +15,15 @@ import {
Settings,
User,
LogOut,
Terminal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useTheme } from '@/hooks';
import { LanguageSwitcher } from './LanguageSwitcher';
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
export interface HeaderProps {
/** Callback to toggle mobile sidebar */
@@ -30,6 +34,8 @@ export interface HeaderProps {
onRefresh?: () => void;
/** Whether refresh is in progress */
isRefreshing?: boolean;
/** Callback to open CLI monitor */
onCliMonitorClick?: () => void;
}
export function Header({
@@ -37,9 +43,11 @@ export function Header({
projectPath = '',
onRefresh,
isRefreshing = false,
onCliMonitorClick,
}: HeaderProps) {
const { formatMessage } = useIntl();
const { isDark, toggleTheme } = useTheme();
const activeCliCount = useCliStreamStore(selectActiveExecutionCount);
const handleRefresh = useCallback(() => {
if (onRefresh && !isRefreshing) {
@@ -47,11 +55,6 @@ export function Header({
}
}, [onRefresh, isRefreshing]);
// Get display path (truncate if too long)
const displayPath = projectPath.length > 40
? '...' + projectPath.slice(-37)
: projectPath || formatMessage({ id: 'navigation.header.noProject' });
return (
<header
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
@@ -83,14 +86,24 @@ export function Header({
{/* Right side - Actions */}
<div className="flex items-center gap-2">
{/* Project path indicator */}
{projectPath && (
<div className="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm text-muted-foreground max-w-[300px]">
<span className="truncate" title={projectPath}>
{displayPath}
</span>
</div>
)}
{/* CLI Monitor button */}
<Button
variant="ghost"
size="sm"
onClick={onCliMonitorClick}
className="gap-2"
>
<Terminal className="h-4 w-4" />
<span className="hidden sm:inline">CLI Monitor</span>
{activeCliCount > 0 && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
{activeCliCount}
</Badge>
)}
</Button>
{/* Workspace selector */}
{projectPath && <WorkspaceSelector />}
{/* Refresh button */}
{onRefresh && (

View File

@@ -22,6 +22,11 @@ import {
LayoutDashboard,
Clock,
Zap,
GitFork,
Shield,
History,
Folder,
Network,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -58,7 +63,12 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
{ path: '/skills', icon: Sparkles },
{ path: '/commands', icon: Terminal },
{ path: '/memory', icon: Brain },
{ path: '/prompts', icon: History },
{ path: '/hooks', icon: GitFork },
{ path: '/explorer', icon: Folder },
{ path: '/graph', icon: Network },
{ path: '/settings', icon: Settings },
{ path: '/settings/rules', icon: Shield },
{ path: '/help', icon: HelpCircle },
];
@@ -103,7 +113,12 @@ export function Sidebar({
'/skills': 'main.skills',
'/commands': 'main.commands',
'/memory': 'main.memory',
'/prompts': 'main.prompts',
'/hooks': 'main.hooks',
'/explorer': 'main.explorer',
'/graph': 'main.graph',
'/settings': 'main.settings',
'/settings/rules': 'main.rules',
'/help': 'main.help',
};
return navItemDefinitions.map((item) => ({
@@ -127,12 +142,11 @@ export function Sidebar({
<aside
className={cn(
'bg-sidebar-background border-r border-border flex flex-col transition-all duration-300',
// Desktop styles
'hidden md:flex sticky top-14 h-[calc(100vh-56px)]',
// Desktop styles - fixed position for floating behavior
'hidden md:flex fixed left-0 top-14 h-[calc(100vh-56px)] z-40',
isCollapsed ? 'w-16' : 'w-64',
// Mobile styles
'md:translate-x-0',
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
mobileOpen && 'flex z-50 w-64 shadow-lg'
)}
role="navigation"
aria-label={formatMessage({ id: 'navigation.header.brand' })}

View File

@@ -0,0 +1,433 @@
// ========================================
// CCW Tools MCP Card Component
// ========================================
// Special card component for CCW Tools MCP server configuration
// Displays tool checkboxes, path settings, and install/uninstall actions
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useMutation } from '@tanstack/react-query';
import {
Settings,
Check,
FolderTree,
Shield,
Database,
FileText,
HardDrive,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import {
installCcwMcp,
uninstallCcwMcp,
updateCcwConfig,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* CCW Tool definition with name, description, and core flag
*/
export interface CcwTool {
name: string;
desc: string;
core: boolean;
}
/**
* CCW MCP configuration interface
*/
export interface CcwConfig {
enabledTools: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}
/**
* Props for CcwToolsMcpCard component
*/
export interface CcwToolsMcpCardProps {
/** Whether CCW MCP is installed */
isInstalled: boolean;
/** List of enabled tool names */
enabledTools: string[];
/** Project root path */
projectRoot?: string;
/** Comma-separated list of allowed directories */
allowedDirs?: string;
/** Whether sandbox is disabled */
disableSandbox?: boolean;
/** Callback when a tool is toggled */
onToggleTool: (tool: string, enabled: boolean) => void;
/** Callback when configuration is updated */
onUpdateConfig: (config: Partial<CcwConfig>) => void;
/** Callback when install/uninstall is triggered */
onInstall: () => void;
}
// ========== Constants ==========
/**
* CCW MCP Tools definition
* Available tools that can be enabled/disabled in CCW MCP server
*/
export const CCW_MCP_TOOLS: CcwTool[] = [
{ name: 'write_file', desc: 'Write/create files', core: true },
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
{ name: 'read_file', desc: 'Read file contents', core: true },
{ name: 'core_memory', desc: 'Core memory management', core: true },
];
// ========== Component ==========
export function CcwToolsMcpCard({
isInstalled,
enabledTools,
projectRoot,
allowedDirs,
disableSandbox,
onToggleTool,
onUpdateConfig,
onInstall,
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
// Local state for config inputs
const [projectRootInput, setProjectRootInput] = useState(projectRoot || '');
const [allowedDirsInput, setAllowedDirsInput] = useState(allowedDirs || '');
const [disableSandboxInput, setDisableSandboxInput] = useState(disableSandbox || false);
const [isExpanded, setIsExpanded] = useState(false);
// Mutations for install/uninstall
const installMutation = useMutation({
mutationFn: installCcwMcp,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
onInstall();
},
});
const uninstallMutation = useMutation({
mutationFn: uninstallCcwMcp,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
onInstall();
},
});
const updateConfigMutation = useMutation({
mutationFn: updateCcwConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
},
});
// Handlers
const handleToggleTool = (toolName: string, enabled: boolean) => {
onToggleTool(toolName, enabled);
};
const handleEnableAll = () => {
CCW_MCP_TOOLS.forEach((tool) => {
if (!enabledTools.includes(tool.name)) {
onToggleTool(tool.name, true);
}
});
};
const handleDisableAll = () => {
enabledTools.forEach((toolName) => {
onToggleTool(toolName, false);
});
};
const handleConfigSave = () => {
updateConfigMutation.mutate({
projectRoot: projectRootInput || undefined,
allowedDirs: allowedDirsInput || undefined,
disableSandbox: disableSandboxInput,
});
};
const handleInstallClick = () => {
installMutation.mutate();
};
const handleUninstallClick = () => {
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallConfirm' }))) {
uninstallMutation.mutate();
}
};
const isPending = installMutation.isPending || uninstallMutation.isPending || updateConfigMutation.isPending;
return (
<Card className={cn(
'overflow-hidden border-2',
isInstalled ? 'border-primary/50 bg-primary/5' : 'border-dashed'
)}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
isInstalled ? 'bg-primary/20' : 'bg-muted'
)}>
<Settings className={cn(
'w-5 h-5',
isInstalled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.ccw.title' })}
</span>
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
</Badge>
{isInstalled && (
<Badge variant="outline" className="text-xs text-info">
{formatMessage({ id: 'mcp.ccw.status.special' })}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.ccw.description' })}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isExpanded ? (
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
</div>
) : (
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
</div>
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
{/* Quick Select Buttons */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleEnableAll}
disabled={!isInstalled}
>
<Check className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.ccw.actions.enableAll' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDisableAll}
disabled={!isInstalled}
>
{formatMessage({ id: 'mcp.ccw.actions.disableAll' })}
</Button>
</div>
{/* Tool Checkboxes */}
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.ccw.tools.label' })}
</p>
<div className="space-y-2">
{CCW_MCP_TOOLS.map((tool) => {
const isEnabled = enabledTools.includes(tool.name);
const icon = getToolIcon(tool.name);
return (
<div
key={tool.name}
className={cn(
'flex items-center gap-3 p-2 rounded-lg transition-colors',
isEnabled ? 'bg-background' : 'bg-muted/50'
)}
>
<input
type="checkbox"
id={`ccw-tool-${tool.name}`}
checked={isEnabled}
onChange={(e) => handleToggleTool(tool.name, e.target.checked)}
disabled={!isInstalled}
className="w-4 h-4"
/>
<label
htmlFor={`ccw-tool-${tool.name}`}
className="flex items-center gap-2 flex-1 cursor-pointer"
>
{icon}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: `mcp.ccw.tools.${tool.name}.name` })}
</span>
{tool.core && (
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'mcp.ccw.tools.core' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: `mcp.ccw.tools.${tool.name}.desc` })}
</p>
</div>
</label>
</div>
);
})}
</div>
</div>
{/* Path Configuration */}
<div className="space-y-3 pt-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.ccw.paths.label' })}
</p>
{/* Project Root */}
<div className="space-y-1">
<label className="text-sm text-foreground flex items-center gap-1">
<FolderTree className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.projectRoot' })}
</label>
<Input
value={projectRootInput}
onChange={(e) => setProjectRootInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
</div>
{/* Allowed Dirs */}
<div className="space-y-1">
<label className="text-sm text-foreground flex items-center gap-1">
<HardDrive className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.allowedDirs' })}
</label>
<Input
value={allowedDirsInput}
onChange={(e) => setAllowedDirsInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.ccw.paths.allowedDirsHint' })}
</p>
</div>
{/* Disable Sandbox */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="ccw-disable-sandbox"
checked={disableSandboxInput}
onChange={(e) => setDisableSandboxInput(e.target.checked)}
disabled={!isInstalled}
className="w-4 h-4"
/>
<label
htmlFor="ccw-disable-sandbox"
className="text-sm text-foreground flex items-center gap-1 cursor-pointer"
>
<Shield className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.disableSandbox' })}
</label>
</div>
{/* Save Config Button */}
{isInstalled && (
<Button
variant="outline"
size="sm"
onClick={handleConfigSave}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.saving' })
: formatMessage({ id: 'mcp.ccw.actions.saveConfig' })
}
</Button>
)}
</div>
{/* Install/Uninstall Button */}
<div className="pt-3 border-t border-border">
{!isInstalled ? (
<Button
onClick={handleInstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.installing' })
: formatMessage({ id: 'mcp.ccw.actions.install' })
}
</Button>
) : (
<Button
variant="destructive"
onClick={handleUninstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
}
</Button>
)}
</div>
</div>
)}
</Card>
);
}
// ========== Helper Functions ==========
/**
* Get icon component for a tool name
*/
function getToolIcon(toolName: string): React.ReactElement {
const iconProps = { className: 'w-4 h-4 text-muted-foreground' };
switch (toolName) {
case 'write_file':
return <FileText {...iconProps} />;
case 'edit_file':
return <Check {...iconProps} />;
case 'read_file':
return <Database {...iconProps} />;
case 'core_memory':
return <Settings {...iconProps} />;
default:
return <Settings {...iconProps} />;
}
}
export default CcwToolsMcpCard;

View File

@@ -0,0 +1,76 @@
// ========================================
// CLI Mode Toggle Component
// ========================================
// Toggle between Claude and Codex CLI modes with config path display
import { useIntl } from 'react-intl';
import { Terminal, Cpu } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export type CliMode = 'claude' | 'codex';
export interface CliModeToggleProps {
currentMode: CliMode;
onModeChange: (mode: CliMode) => void;
codexConfigPath?: string;
}
// ========== Component ==========
export function CliModeToggle({
currentMode,
onModeChange,
codexConfigPath,
}: CliModeToggleProps) {
const { formatMessage } = useIntl();
return (
<div className="space-y-3">
{/* Mode Toggle Buttons */}
<div className="flex gap-2 p-1 bg-muted rounded-lg">
<Button
variant={currentMode === 'claude' ? 'default' : 'ghost'}
size="sm"
onClick={() => onModeChange('claude')}
className={cn(
'flex-1 gap-2',
currentMode === 'claude' && 'shadow-sm'
)}
>
<Terminal className="w-4 h-4" />
<span>{formatMessage({ id: 'mcp.mode.claude' })}</span>
</Button>
<Button
variant={currentMode === 'codex' ? 'default' : 'ghost'}
size="sm"
onClick={() => onModeChange('codex')}
className={cn(
'flex-1 gap-2',
currentMode === 'codex' && 'shadow-sm'
)}
>
<Cpu className="w-4 h-4" />
<span>{formatMessage({ id: 'mcp.mode.codex' })}</span>
</Button>
</div>
{/* Codex Config Path Display */}
{currentMode === 'codex' && codexConfigPath && (
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'mcp.codex.configPath' })}
</Badge>
<code className="text-xs text-muted-foreground font-mono truncate flex-1">
{codexConfigPath}
</code>
</div>
)}
</div>
);
}
export default CliModeToggle;

View File

@@ -0,0 +1,152 @@
// ========================================
// Codex MCP Card Component
// ========================================
// Read-only display card for Codex MCP servers (no edit/delete)
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
ChevronDown,
ChevronUp,
Lock,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/lib/api';
// ========== Types ==========
export interface CodexMcpCardProps {
server: McpServer;
enabled: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
}
// ========== Component ==========
export function CodexMcpCard({
server,
enabled,
isExpanded,
onToggleExpand,
}: CodexMcpCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn('overflow-hidden', !enabled && 'opacity-60')}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Server className={cn(
'w-5 h-5',
enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{server.name}
</span>
{/* Read-only badge */}
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Lock className="w-3 h-3" />
{formatMessage({ id: 'mcp.codex.readOnly' })}
</Badge>
{enabled && (
<Badge variant="outline" className="text-xs text-green-600">
<Power className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{server.command} {server.args?.join(' ') || ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Disabled toggle button (visual only, no edit capability) */}
<div className={cn(
'w-8 h-8 rounded-md flex items-center justify-center',
enabled ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
)}>
{enabled ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
{/* Command details */}
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
{server.command}
</code>
</div>
{/* Args */}
{server.args && server.args.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
<div className="flex flex-wrap gap-1">
{server.args.map((arg, idx) => (
<Badge key={idx} variant="outline" className="font-mono text-xs">
{arg}
</Badge>
))}
</div>
</div>
)}
{/* Environment variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
<div className="space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-mono">{key}</Badge>
<span className="text-muted-foreground">=</span>
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
{value as string}
</code>
</div>
))}
</div>
</div>
)}
{/* Read-only notice */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
<Lock className="w-4 h-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.codex.readOnlyNotice' })}
</p>
</div>
</div>
)}
</Card>
);
}
export default CodexMcpCard;

View File

@@ -0,0 +1,520 @@
// ========================================
// MCP Server Dialog Component
// ========================================
// Add/Edit dialog for MCP server configuration with template presets
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
createMcpServer,
updateMcpServer,
fetchMcpServers,
type McpServer,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface McpServerDialogProps {
mode: 'add' | 'edit';
server?: McpServer;
open: boolean;
onClose: () => void;
onSave?: () => void;
}
export interface McpTemplate {
id: string;
name: string;
description: string;
command: string;
args: string[];
env?: Record<string, string>;
}
interface McpServerFormData {
name: string;
command: string;
args: string[];
env: Record<string, string>;
scope: 'project' | 'global';
enabled: boolean;
}
interface FormErrors {
name?: string;
command?: string;
args?: string;
env?: string;
}
// ========== Template Presets ==========
const TEMPLATE_PRESETS: McpTemplate[] = [
{
id: 'npx-stdio',
name: 'NPX STDIO',
description: 'Node.js package using stdio transport',
command: 'npx',
args: ['{package}'],
},
{
id: 'python-stdio',
name: 'Python STDIO',
description: 'Python script using stdio transport',
command: 'python',
args: ['{script}.py'],
},
{
id: 'sse-server',
name: 'SSE Server',
description: 'HTTP server with Server-Sent Events transport',
command: 'node',
args: ['{server}.js'],
},
];
// ========== Component ==========
export function McpServerDialog({
mode,
server,
open,
onClose,
onSave,
}: McpServerDialogProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
// Form state
const [formData, setFormData] = useState<McpServerFormData>({
name: '',
command: '',
args: [],
env: {},
scope: 'project',
enabled: true,
});
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
const [errors, setErrors] = useState<FormErrors>({});
const [argsInput, setArgsInput] = useState('');
const [envInput, setEnvInput] = useState('');
// Initialize form from server prop (edit mode)
useEffect(() => {
if (server && mode === 'edit') {
setFormData({
name: server.name,
command: server.command,
args: server.args || [],
env: server.env || {},
scope: server.scope,
enabled: server.enabled,
});
setArgsInput((server.args || []).join(', '));
setEnvInput(
Object.entries(server.env || {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
);
} else {
// Reset form for add mode
setFormData({
name: '',
command: '',
args: [],
env: {},
scope: 'project',
enabled: true,
});
setArgsInput('');
setEnvInput('');
}
setSelectedTemplate('');
setErrors({});
}, [server, mode, open]);
// Mutations
const createMutation = useMutation({
mutationFn: (data: Omit<McpServer, 'name'>) => createMcpServer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
handleClose();
onSave?.();
},
});
const updateMutation = useMutation({
mutationFn: ({ serverName, config }: { serverName: string; config: Partial<McpServer> }) =>
updateMcpServer(serverName, config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
handleClose();
onSave?.();
},
});
// Handlers
const handleClose = () => {
setErrors({});
onClose();
};
const handleTemplateSelect = (templateId: string) => {
const template = TEMPLATE_PRESETS.find((t) => t.id === templateId);
if (template) {
setFormData((prev) => ({
...prev,
command: template.command,
args: template.args,
env: template.env || {},
}));
setArgsInput(template.args.join(', '));
setEnvInput(
Object.entries(template.env || {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
);
setSelectedTemplate(templateId);
}
};
const handleFieldChange = (
field: keyof McpServerFormData,
value: string | boolean | string[] | Record<string, string>
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error for this field
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleArgsChange = (value: string) => {
setArgsInput(value);
const argsArray = value
.split(',')
.map((a) => a.trim())
.filter((a) => a.length > 0);
setFormData((prev) => ({ ...prev, args: argsArray }));
if (errors.args) {
setErrors((prev) => ({ ...prev, args: undefined }));
}
};
const handleEnvChange = (value: string) => {
setEnvInput(value);
const envObj: Record<string, string> = {};
const lines = value.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && trimmed.includes('=')) {
const [key, ...valParts] = trimmed.split('=');
const val = valParts.join('=');
if (key) {
envObj[key.trim()] = val.trim();
}
}
}
setFormData((prev) => ({ ...prev, env: envObj }));
if (errors.env) {
setErrors((prev) => ({ ...prev, env: undefined }));
}
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Name required
if (!formData.name.trim()) {
newErrors.name = formatMessage({ id: 'mcp.dialog.validation.nameRequired' });
}
// Command required
if (!formData.command.trim()) {
newErrors.command = formatMessage({ id: 'mcp.dialog.validation.commandRequired' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const checkNameExists = async (name: string): Promise<boolean> => {
try {
const data = await fetchMcpServers();
const allServers = [...data.project, ...data.global];
// In edit mode, exclude current server
return allServers.some(
(s) => s.name === name && (mode === 'edit' ? s.name !== server?.name : true)
);
} catch {
return false;
}
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
// Check name uniqueness
if (await checkNameExists(formData.name)) {
setErrors({ name: formatMessage({ id: 'mcp.dialog.validation.nameExists' }) });
return;
}
if (mode === 'add') {
createMutation.mutate({
command: formData.command,
args: formData.args,
env: formData.env,
scope: formData.scope,
enabled: formData.enabled,
});
} else {
updateMutation.mutate({
serverName: server!.name,
config: {
command: formData.command,
args: formData.args,
env: formData.env,
scope: formData.scope,
enabled: formData.enabled,
},
});
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'add'
? formatMessage({ id: 'mcp.dialog.addTitle' })
: formatMessage({ id: 'mcp.dialog.editTitle' }, { name: server?.name })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Template Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.template' })}
</label>
<Select value={selectedTemplate} onValueChange={handleTemplateSelect}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
/>
</SelectTrigger>
<SelectContent>
{TEMPLATE_PRESETS.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.name' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={formData.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={formatMessage({ id: 'mcp.dialog.form.namePlaceholder' })}
error={!!errors.name}
disabled={mode === 'edit'} // Name cannot be changed in edit mode
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
{/* Command */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.command' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={formData.command}
onChange={(e) => handleFieldChange('command', e.target.value)}
placeholder={formatMessage({ id: 'mcp.dialog.form.commandPlaceholder' })}
error={!!errors.command}
/>
{errors.command && (
<p className="text-sm text-destructive">{errors.command}</p>
)}
</div>
{/* Args */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.args' })}
</label>
<Input
value={argsInput}
onChange={(e) => handleArgsChange(e.target.value)}
placeholder={formatMessage({ id: 'mcp.dialog.form.argsPlaceholder' })}
error={!!errors.args}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.dialog.form.argsHint' })}
</p>
{formData.args.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{formData.args.map((arg, idx) => (
<Badge key={idx} variant="secondary" className="font-mono text-xs">
{arg}
</Badge>
))}
</div>
)}
{errors.args && (
<p className="text-sm text-destructive">{errors.args}</p>
)}
</div>
{/* Environment Variables */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.env' })}
</label>
<textarea
value={envInput}
onChange={(e) => handleEnvChange(e.target.value)}
placeholder={formatMessage({ id: 'mcp.dialog.form.envPlaceholder' })}
className={cn(
'flex min-h-[100px] 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',
errors.env && 'border-destructive focus-visible:ring-destructive'
)}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.dialog.form.envHint' })}
</p>
{Object.keys(formData.env).length > 0 && (
<div className="space-y-1 mt-2">
{Object.entries(formData.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="font-mono">
{key}
</Badge>
<span className="text-muted-foreground">=</span>
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 overflow-x-auto">
{value}
</code>
</div>
))}
</div>
)}
{errors.env && (
<p className="text-sm text-destructive">{errors.env}</p>
)}
</div>
{/* Scope */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.scope' })}
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope"
value="project"
checked={formData.scope === 'project'}
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
className="w-4 h-4"
/>
<span className="text-sm">
{formatMessage({ id: 'mcp.scope.project' })}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scope"
value="global"
checked={formData.scope === 'global'}
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
className="w-4 h-4"
/>
<span className="text-sm">
{formatMessage({ id: 'mcp.scope.global' })}
</span>
</label>
</div>
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={formData.enabled}
onChange={(e) => handleFieldChange('enabled', e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enabled" className="text-sm font-medium text-foreground cursor-pointer">
{formatMessage({ id: 'mcp.dialog.form.enabled' })}
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isPending}
>
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
>
{isPending
? formatMessage({ id: 'mcp.dialog.actions.saving' })
: formatMessage({ id: 'mcp.dialog.actions.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default McpServerDialog;

View File

@@ -0,0 +1,405 @@
// ========================================
// NotificationPanel Component
// ========================================
// Slide-over drawer notification panel with persistent notifications
import { useState, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Bell,
X,
Check,
Trash2,
ChevronDown,
ChevronUp,
Info,
CheckCircle,
AlertTriangle,
XCircle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useNotificationStore, selectPersistentNotifications } from '@/stores';
import type { Toast } from '@/types/store';
// ========== Helper Functions ==========
function formatTimeAgo(timestamp: string, formatMessage: (message: { id: string; values?: Record<string, unknown> }) => string): string {
const now = Date.now();
const time = new Date(timestamp).getTime();
const diffMs = now - time;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return formatMessage({ id: 'notifications.justNow' });
if (minutes < 60) {
return formatMessage({
id: minutes === 1 ? 'notifications.oneMinuteAgo' : 'notifications.minutesAgo',
values: { 0: String(minutes) }
});
}
if (hours < 24) {
return formatMessage({
id: hours === 1 ? 'notifications.oneHourAgo' : 'notifications.hoursAgo',
values: { 0: String(hours) }
});
}
if (days < 7) {
return formatMessage({
id: days === 1 ? 'notifications.oneDayAgo' : 'notifications.daysAgo',
values: { 0: String(days) }
});
}
return new Date(timestamp).toLocaleDateString();
}
function formatDetails(details: unknown): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
if (typeof details === 'string') return details;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
if (typeof details === 'object' && details !== null) {
return JSON.stringify(details, null, 2);
}
return String(details);
}
function getNotificationIcon(type: Toast['type']) {
const iconClassName = 'h-4 w-4 shrink-0';
switch (type) {
case 'success':
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
case 'warning':
return <AlertTriangle className={cn(iconClassName, 'text-yellow-500')} />;
case 'error':
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
case 'info':
default:
return <Info className={cn(iconClassName, 'text-blue-500')} />;
}
}
// ========== Sub-Components ==========
interface PanelHeaderProps {
notificationCount: number;
onClose: () => void;
}
function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
const { formatMessage } = useIntl();
return (
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'notificationPanel.title' }) || 'Notifications'}
</h2>
{notificationCount > 0 && (
<Badge variant="default" className="h-5 px-1.5 text-xs">
{notificationCount}
</Badge>
)}
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
);
}
interface PanelActionsProps {
hasNotifications: boolean;
hasUnread: boolean;
onMarkAllRead: () => void;
onClearAll: () => void;
}
function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }: PanelActionsProps) {
const { formatMessage } = useIntl();
if (!hasNotifications) return null;
return (
<div className="flex items-center justify-between px-4 py-2 bg-secondary/30 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={onMarkAllRead}
disabled={!hasUnread}
className="h-7 text-xs"
>
<Check className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notificationPanel.markAllRead' }) || 'Mark Read'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-7 text-xs text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
{formatMessage({ id: 'notificationPanel.clearAll' }) || 'Clear All'}
</Button>
</div>
);
}
interface NotificationItemProps {
notification: Toast;
onDelete: (id: string) => void;
}
function NotificationItem({ notification, onDelete }: NotificationItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const hasDetails = notification.message && notification.message.length > 100;
const { formatMessage } = useIntl();
return (
<div
className={cn(
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
// Read opacity will be handled in T5 when read field is added
'opacity-100'
)}
>
<div className="flex gap-3">
{/* Icon */}
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-foreground truncate">
{notification.title}
</h4>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatTimeAgo(notification.timestamp, formatMessage)}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => onDelete(notification.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{notification.message && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{isExpanded || !hasDetails
? notification.message
: notification.message.slice(0, 100) + '...'}
</p>
)}
{/* Expand toggle */}
{hasDetails && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 mt-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
</>
)}
</button>
)}
{/* Action button */}
{notification.action && (
<Button
variant="outline"
size="sm"
onClick={notification.action.onClick}
className="mt-2 h-7 text-xs"
>
{notification.action.label}
</Button>
)}
</div>
</div>
</div>
);
}
interface NotificationListProps {
notifications: Toast[];
onDelete: (id: string) => void;
}
function NotificationList({ notifications, onDelete }: NotificationListProps) {
if (notifications.length === 0) return null;
return (
<div className="flex-1 overflow-y-auto">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onDelete={onDelete}
/>
))}
</div>
);
}
interface EmptyStateProps {
message?: string;
}
function EmptyState({ message }: EmptyStateProps) {
const { formatMessage } = useIntl();
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Bell className="h-16 w-16 mx-auto mb-4 opacity-30" />
<p className="text-sm">
{message ||
formatMessage({ id: 'notificationPanel.empty' }) ||
'No notifications'}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'notificationPanel.emptyHint' }) ||
'Notifications will appear here'}
</p>
</div>
</div>
);
}
// ========== Main Component ==========
export interface NotificationPanelProps {
isOpen: boolean;
onClose: () => void;
}
export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
const { formatMessage } = useIntl();
// Store state
const persistentNotifications = useNotificationStore(selectPersistentNotifications);
const removePersistentNotification = useNotificationStore(
(state) => state.removePersistentNotification
);
const clearPersistentNotifications = useNotificationStore(
(state) => state.clearPersistentNotifications
);
// Check if markAllAsRead exists (will be added in T5)
const store = useNotificationStore.getState();
const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined;
// Reverse chronological order (newest first)
const sortedNotifications = [...persistentNotifications].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// Delete handler
const handleDelete = useCallback(
(id: string) => {
removePersistentNotification(id);
},
[removePersistentNotification]
);
// Mark all read handler
const handleMarkAllRead = useCallback(() => {
if (markAllAsRead) {
markAllAsRead();
} else {
// Placeholder for T5
console.log('[NotificationPanel] markAllAsRead will be implemented in T5');
}
}, [markAllAsRead]);
// Clear all handler
const handleClearAll = useCallback(() => {
clearPersistentNotifications();
}, [clearPersistentNotifications]);
// ESC key to close
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
// Check for unread notifications (will be enhanced in T5 with read field)
// For now, all notifications are considered "unread" for UI purposes
const hasUnread = sortedNotifications.length > 0;
if (!isOpen) {
return null;
}
return (
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 bg-black/40 transition-opacity z-40',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-full md:w-[480px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"
aria-modal="true"
aria-labelledby="notification-panel-title"
>
{/* Header */}
<PanelHeader notificationCount={sortedNotifications.length} onClose={onClose} />
{/* Action Bar */}
<PanelActions
hasNotifications={sortedNotifications.length > 0}
hasUnread={hasUnread}
onMarkAllRead={handleMarkAllRead}
onClearAll={handleClearAll}
/>
{/* Content */}
{sortedNotifications.length > 0 ? (
<NotificationList
notifications={sortedNotifications}
onDelete={handleDelete}
/>
) : (
<EmptyState />
)}
</div>
</>
);
}
export default NotificationPanel;

View File

@@ -0,0 +1,7 @@
// ========================================
// Notification Components Index
// ========================================
// Centralized exports for notification components
export { NotificationPanel } from './NotificationPanel';
export type { NotificationPanelProps } from './NotificationPanel';

View File

@@ -0,0 +1,519 @@
// ========================================
// CliStreamMonitor Component
// ========================================
// Global CLI streaming monitor with multi-execution support
import { useEffect, useRef, useCallback, useState } from 'react';
import { useIntl } from 'react-intl';
import {
X,
Terminal,
Loader2,
AlertCircle,
Clock,
RefreshCw,
Search,
XCircle,
ArrowDownToLine,
Brain,
Settings,
Info,
MessageCircle,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
import { useNotificationStore, selectWsLastMessage } from '@/stores';
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
// ========== Types for CLI WebSocket Messages ==========
interface CliStreamStartedPayload {
executionId: string;
tool: string;
mode: string;
timestamp: string;
}
interface CliStreamOutputPayload {
executionId: string;
chunkType: string;
data: unknown;
unit?: {
content: unknown;
type?: string;
};
}
interface CliStreamCompletedPayload {
executionId: string;
success: boolean;
duration?: number;
timestamp: string;
}
interface CliStreamErrorPayload {
executionId: string;
error?: string;
timestamp: string;
}
// ========== Helper Functions ==========
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
function getOutputLineIcon(type: CliOutputLine['type']) {
switch (type) {
case 'thought':
return <Brain className="h-3 w-3" />;
case 'system':
return <Settings className="h-3 w-3" />;
case 'stderr':
return <AlertCircle className="h-3 w-3" />;
case 'metadata':
return <Info className="h-3 w-3" />;
case 'tool_call':
return <Wrench className="h-3 w-3" />;
case 'stdout':
default:
return <MessageCircle className="h-3 w-3" />;
}
}
function getOutputLineClass(type: CliOutputLine['type']): string {
switch (type) {
case 'thought':
return 'text-purple-400';
case 'system':
return 'text-blue-400';
case 'stderr':
return 'text-red-400';
case 'metadata':
return 'text-yellow-400';
case 'tool_call':
return 'text-green-400';
case 'stdout':
default:
return 'text-foreground';
}
}
// ========== Component ==========
export interface CliStreamMonitorProps {
isOpen: boolean;
onClose: () => void;
}
export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
const { formatMessage } = useIntl();
const logsEndRef = useRef<HTMLDivElement>(null);
const logsContainerRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const [autoScroll, setAutoScroll] = useState(true);
const [isUserScrolling, setIsUserScrolling] = useState(false);
// Store state
const executions = useCliStreamStore((state) => state.executions);
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
const removeExecution = useCliStreamStore((state) => state.removeExecution);
// Active execution sync
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
const invalidateActive = useInvalidateActiveCliExecutions();
// WebSocket last message from notification store
const lastMessage = useNotificationStore(selectWsLastMessage);
// Handle WebSocket messages for CLI stream
useEffect(() => {
if (!lastMessage) return;
const { type, payload } = lastMessage;
if (type === 'CLI_STARTED') {
const p = payload as CliStreamStartedPayload;
const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
tool: p.tool || 'cli',
mode: p.mode || 'analysis',
status: 'running',
startTime,
output: [
{
type: 'system',
content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`,
timestamp: startTime
}
]
});
// Set as current if none selected
if (!currentExecutionId) {
setCurrentExecution(p.executionId);
}
invalidateActive();
} else if (type === 'CLI_OUTPUT') {
const p = payload as CliStreamOutputPayload;
const unitContent = p.unit?.content;
const unitType = p.unit?.type || p.chunkType;
let content: string;
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string };
if (toolCall.action === 'invoke') {
const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : '';
content = `[Tool] ${toolCall.toolName}(${params})`;
} else if (toolCall.action === 'result') {
const status = toolCall.status || 'unknown';
const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : '';
content = `[Tool Result] ${status}${output}`;
} else {
content = JSON.stringify(unitContent);
}
} else {
content = typeof p.data === 'string' ? p.data : JSON.stringify(p.data);
}
const lines = content.split('\n');
const addOutput = useCliStreamStore.getState().addOutput;
lines.forEach(line => {
if (line.trim() || lines.length === 1) {
addOutput(p.executionId, {
type: (unitType as CliOutputLine['type']) || 'stdout',
content: line,
timestamp: Date.now()
});
}
});
} else if (type === 'CLI_COMPLETED') {
const p = payload as CliStreamCompletedPayload;
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
status: p.success ? 'completed' : 'error',
endTime,
output: [
{
type: 'system',
content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`,
timestamp: endTime
}
]
});
invalidateActive();
} else if (type === 'CLI_ERROR') {
const p = payload as CliStreamErrorPayload;
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
status: 'error',
endTime,
output: [
{
type: 'stderr',
content: `[ERROR] ${p.error || 'Unknown error occurred'}`,
timestamp: endTime
}
]
});
invalidateActive();
}
}, [lastMessage, currentExecutionId, setCurrentExecution, invalidateActive]);
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (autoScroll && !isUserScrolling && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [executions, autoScroll, isUserScrolling, currentExecutionId]);
// Handle scroll to detect user scrolling
const handleScroll = useCallback(() => {
if (!logsContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsUserScrolling(!isAtBottom);
}, []);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setIsUserScrolling(false);
}, []);
// ESC key to close
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
if (searchQuery) {
setSearchQuery('');
} else {
onClose();
}
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, searchQuery]);
// Get sorted execution IDs (running first, then by start time)
const sortedExecutionIds = Object.keys(executions).sort((a, b) => {
const execA = executions[a];
const execB = executions[b];
if (execA.status === 'running' && execB.status !== 'running') return -1;
if (execA.status !== 'running' && execB.status === 'running') return 1;
return execB.startTime - execA.startTime;
});
// Active execution count for badge
const activeCount = Object.values(executions).filter(e => e.status === 'running').length;
// Current execution
const currentExecution = currentExecutionId ? executions[currentExecutionId] : null;
// Filter output lines based on search
const filteredOutput = currentExecution && searchQuery
? currentExecution.output.filter(line =>
line.content.toLowerCase().includes(searchQuery.toLowerCase())
)
: currentExecution?.output || [];
if (!isOpen) {
return null;
}
return (
<>
{/* Overlay */}
<div
className={cn(
'fixed inset-0 bg-black/40 transition-opacity z-40',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
'fixed top-0 right-0 h-full w-[600px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"
aria-modal="true"
aria-labelledby="cli-monitor-title"
>
{/* Header */}
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<h2 id="cli-monitor-title" className="text-sm font-semibold text-foreground">
CLI Stream Monitor
</h2>
{activeCount > 0 && (
<Badge variant="default" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{activeCount} active
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => refetch()}
disabled={isSyncing}
title="Refresh"
>
<RefreshCw className={cn('h-4 w-4', isSyncing && 'animate-spin')} />
</Button>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
</div>
{/* Execution Tabs */}
{sortedExecutionIds.length > 0 && (
<div className="px-4 pt-3 bg-card border-b border-border">
<Tabs
value={currentExecutionId || ''}
onValueChange={(v) => setCurrentExecution(v || null)}
className="w-full"
>
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
{sortedExecutionIds.map((id) => {
const exec = executions[id];
return (
<TabsTrigger
key={id}
value={id}
className={cn(
'gap-1.5 text-xs px-2 py-1',
exec.status === 'running' && 'bg-primary text-primary-foreground'
)}
>
<span className={cn('w-1.5 h-1.5 rounded-full', {
'bg-green-500 animate-pulse': exec.status === 'running',
'bg-blue-500': exec.status === 'completed',
'bg-red-500': exec.status === 'error'
})} />
<span className="font-medium">{exec.tool}</span>
<span className="text-muted-foreground">{exec.mode}</span>
{exec.recovered && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
Recovered
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1 hover:bg-destructive hover:text-destructive-foreground"
onClick={(e) => {
e.stopPropagation();
removeExecution(id);
}}
>
<XCircle className="h-3 w-3" />
</Button>
</TabsTrigger>
);
})}
</TabsList>
{/* Output Panel */}
<div className="flex flex-col h-[calc(100vh-180px)]">
{/* Toolbar */}
<div className="flex items-center justify-between px-2 py-2 bg-secondary/30 border-b border-border">
<div className="flex items-center gap-2 flex-1">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'cliMonitor.searchPlaceholder' }) || 'Search output...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-xs"
/>
{searchQuery && (
<Button variant="ghost" size="sm" onClick={() => setSearchQuery('')} className="h-7 px-2">
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
{currentExecution && (
<>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(
currentExecution.endTime
? currentExecution.endTime - currentExecution.startTime
: Date.now() - currentExecution.startTime
)}
</span>
<span className="text-xs text-muted-foreground">
{filteredOutput.length} / {currentExecution.output.length} lines
</span>
</>
)}
<Button
variant={autoScroll ? 'default' : 'ghost'}
size="sm"
onClick={() => setAutoScroll(!autoScroll)}
className="h-7 px-2"
title={autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
>
<ArrowDownToLine className="h-3 w-3" />
</Button>
</div>
</div>
{/* Output Content */}
{currentExecution ? (
<div
ref={logsContainerRef}
className="flex-1 overflow-y-auto p-3 font-mono text-xs bg-background"
onScroll={handleScroll}
>
{filteredOutput.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
</div>
) : (
<div className="space-y-1">
{filteredOutput.map((line, index) => (
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
<span className="text-muted-foreground shrink-0">
{getOutputLineIcon(line.type)}
</span>
<span className="break-all">{line.content}</span>
</div>
))}
<div ref={logsEndRef} />
</div>
)}
{isUserScrolling && filteredOutput.length > 0 && (
<Button
size="sm"
variant="secondary"
className="absolute bottom-4 right-4"
onClick={scrollToBottom}
>
<ArrowDownToLine className="h-4 w-4 mr-1" />
Scroll to bottom
</Button>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Terminal className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">
{sortedExecutionIds.length === 0
? (formatMessage({ id: 'cliMonitor.noExecutions' }) || 'No active CLI executions')
: (formatMessage({ id: 'cliMonitor.selectExecution' }) || 'Select an execution to view output')
}
</p>
</div>
</div>
)}
</div>
</Tabs>
</div>
)}
{/* Empty State */}
{sortedExecutionIds.length === 0 && (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Terminal className="h-16 w-16 mx-auto mb-4 opacity-30" />
<p className="text-sm mb-1">
{formatMessage({ id: 'cliMonitor.noExecutions' }) || 'No active CLI executions'}
</p>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'cliMonitor.noExecutionsHint' }) || 'Start a CLI command to see streaming output'}
</p>
</div>
</div>
)}
</div>
</>
);
}
export default CliStreamMonitor;

View File

@@ -1,53 +1,68 @@
// ========================================
// CliStreamPanel Component
// ========================================
// Floating panel for CLI execution details with streaming output
// Turn-based CLI execution detail view
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Terminal, Clock, Calendar, Hash } from 'lucide-react';
import { User, Bot, AlertTriangle, Info, Layers, Clock, Copy, Terminal, Hash, Calendar, CheckCircle2, XCircle, Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { StreamingOutput } from './StreamingOutput';
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import type { CliOutputLine } from '@/stores/cliStreamStore';
import type { ConversationRecord, ConversationTurn } from '@/lib/api';
// ========== Stable Selectors ==========
// Create selector factory to avoid infinite re-renders
// The selector function itself is stable, preventing unnecessary re-renders
const createOutputsSelector = (executionId: string) => (state: ReturnType<typeof useCliStreamStore.getState>) =>
state.outputs[executionId];
type ViewMode = 'per-turn' | 'concatenated';
type ConcatFormat = 'plain' | 'yaml' | 'json';
export interface CliStreamPanelProps {
/** Execution ID to display */
executionId: string;
/** Source directory path */
sourceDir?: string;
/** Whether panel is open */
open: boolean;
/** Called when open state changes */
onOpenChange: (open: boolean) => void;
}
type TabValue = 'prompt' | 'output' | 'details';
// ========== Types ==========
interface TurnSectionProps {
turn: ConversationTurn;
isLatest: boolean;
}
interface ConcatenatedViewProps {
prompt: string;
format: ConcatFormat;
onFormatChange: (fmt: ConcatFormat) => void;
}
// ========== Helpers ==========
/**
* Format duration to human readable string
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
const seconds = (ms / 1000).toFixed(1);
return `${seconds}s`;
}
/**
* Get status icon and color for a turn
*/
function getStatusInfo(status: string) {
const statusMap = {
success: { icon: CheckCircle2, color: 'text-green-600 dark:text-green-400' },
error: { icon: XCircle, color: 'text-destructive' },
timeout: { icon: Timer, color: 'text-warning' },
};
return statusMap[status as keyof typeof statusMap] || statusMap.error;
}
/**
@@ -58,28 +73,238 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su
gemini: 'info',
codex: 'success',
qwen: 'warning',
opencode: 'secondary',
};
return variants[tool] || 'secondary';
}
/**
* CliStreamPanel component - Display CLI execution details in floating panel
* Build concatenated prompt in specified format
*/
function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFormat, formatMessage: (message: { id: string }) => string): string {
const turns = execution.turns;
if (format === 'plain') {
const parts: string[] = [];
parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.conversationHistory' })} ===`);
parts.push('');
for (const turn of turns) {
parts.push(`--- Turn ${turn.turn} ---`);
parts.push('USER:');
parts.push(turn.prompt);
parts.push('');
parts.push('ASSISTANT:');
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
parts.push('');
}
parts.push(`=== ${formatMessage({ id: 'cli-manager.streamPanel.newRequest' })} ===`);
parts.push('');
parts.push(formatMessage({ id: 'cli-manager.streamPanel.yourNextPrompt' }));
return parts.join('\n');
}
if (format === 'yaml') {
const yaml: string[] = [];
yaml.push('conversation:');
yaml.push(' turns:');
for (const turn of turns) {
yaml.push(` - turn: ${turn.turn}`);
yaml.push(` timestamp: ${turn.timestamp}`);
yaml.push(` prompt: |`);
turn.prompt.split('\n').forEach(line => {
yaml.push(` ${line}`);
});
yaml.push(` response: |`);
const output = turn.output.stdout || '';
if (output) {
output.split('\n').forEach(line => {
yaml.push(` ${line}`);
});
} else {
yaml.push(` ${formatMessage({ id: 'cli-manager.streamPanel.noOutput' })}`);
}
}
return yaml.join('\n');
}
// JSON format
return JSON.stringify(
turns.map((t) => ({
turn: t.turn,
timestamp: t.timestamp,
prompt: t.prompt,
response: t.output.stdout || '',
})),
null,
2
);
}
// ========== Sub-Components ==========
/**
* TurnSection - Single turn display with header and content
*/
function TurnSection({ turn, isLatest }: TurnSectionProps) {
const { formatMessage } = useIntl();
const StatusIcon = getStatusInfo(turn.status as string).icon;
const statusColor = getStatusInfo(turn.status as string).color;
return (
<Card
className={cn(
'overflow-hidden transition-all',
isLatest && 'ring-2 ring-primary/50 shadow-md'
)}
>
{/* Turn Header */}
<div className="flex items-center justify-between px-4 py-3 bg-muted/50 border-b">
<div className="flex items-center gap-2">
<span className="text-lg font-medium" aria-hidden="true">
{turn.turn === 1 ? '\u25B6' : '\u21B3'} {/* ▶ or ↳ */}
</span>
<span className="font-semibold text-sm">{formatMessage({ id: 'cli.details.turn' })} {turn.turn}</span>
{isLatest && (
<Badge variant="default" className="text-xs h-5 px-1.5">
{formatMessage({ id: 'cli-manager.streamPanel.latest' })}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.timestamp' })}>
<Clock className="h-3 w-3" />
{new Date(turn.timestamp).toLocaleTimeString()}
</span>
<span className={cn('flex items-center gap-1 font-medium', statusColor)} title={formatMessage({ id: 'cli.details.status' })}>
<StatusIcon className="h-3.5 w-3.5" />
{turn.status}
</span>
<span className="font-mono text-xs" title={formatMessage({ id: 'cli.details.duration' })}>
{formatDuration(turn.duration_ms)}
</span>
</div>
</div>
{/* Turn Body */}
<div className="p-4 space-y-4">
{/* User Prompt */}
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-foreground">
<User className="h-4 w-4 text-primary" aria-hidden="true" />
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
</h4>
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
{turn.prompt}
</pre>
</div>
{/* Assistant Response */}
{turn.output.stdout && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-foreground">
<Bot className="h-4 w-4 text-blue-500" aria-hidden="true" />
{formatMessage({ id: 'cli-manager.streamPanel.assistantResponse' })}
</h4>
<pre className="p-3 bg-blue-500/5 dark:bg-blue-500/10 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
{turn.output.stdout}
</pre>
</div>
)}
{/* Errors */}
{turn.output.stderr && (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold mb-2 text-destructive">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
{formatMessage({ id: 'cli-manager.streamPanel.errors' })}
</h4>
<pre className="p-3 bg-destructive/10 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed text-destructive">
{turn.output.stderr}
</pre>
</div>
)}
{/* Truncated Notice */}
{turn.output.truncated && (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-3 bg-muted/50 rounded-lg border border-border/50">
<Info className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span>{formatMessage({ id: 'cli-manager.streamPanel.truncatedNotice' })}</span>
</div>
)}
</div>
</Card>
);
}
/**
* PerTurnView - Display all turns as separate sections with connectors
*/
function PerTurnView({ turns }: { turns: ConversationTurn[] }) {
return (
<div className="space-y-4">
{turns.map((turn, idx) => (
<React.Fragment key={turn.turn}>
<TurnSection turn={turn} isLatest={idx === turns.length - 1} />
{/* Connector line between turns */}
{idx < turns.length - 1 && (
<div className="flex justify-center" aria-hidden="true">
<div className="w-px h-6 bg-border" />
</div>
)}
</React.Fragment>
))}
</div>
);
}
/**
* ConcatenatedView - Display all turns merged into a single prompt
*/
function ConcatenatedView({ prompt, format, onFormatChange }: ConcatenatedViewProps) {
const { formatMessage } = useIntl();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="flex items-center gap-2 text-sm font-semibold">
<Layers className="h-4 w-4" aria-hidden="true" />
{formatMessage({ id: 'cli-manager.streamPanel.concatenatedPrompt' })}
</h4>
<div className="flex gap-2">
{(['plain', 'yaml', 'json'] as const).map((fmt) => (
<Button
key={fmt}
size="sm"
variant={format === fmt ? 'default' : 'outline'}
onClick={() => onFormatChange(fmt)}
className="h-7 px-2 text-xs"
>
{fmt.toUpperCase()}
</Button>
))}
</div>
</div>
<pre className="p-4 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-[60vh] overflow-y-auto">
{prompt}
</pre>
</div>
);
}
// ========== Main Component ==========
/**
* CliStreamPanel component - Elegant turn-based conversation view
*
* @remarks
* Shows execution details with three tabs:
* - Prompt: View the conversation prompts
* - Output: Real-time streaming output
* - Details: Execution metadata (tool, mode, duration, etc.)
*
* @example
* ```tsx
* <CliStreamPanel
* executionId="exec-123"
* sourceDir="/path/to/project"
* open={isOpen}
* onOpenChange={setIsOpen}
* />
* ```
* Displays CLI execution details with:
* - Per-turn view with timeline layout
* - Concatenated view for resume context
* - Format selection (Plain/YAML/JSON)
*/
export function CliStreamPanel({
executionId,
@@ -88,49 +313,30 @@ export function CliStreamPanel({
onOpenChange,
}: CliStreamPanelProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = React.useState<TabValue>('output');
const [viewMode, setViewMode] = React.useState<ViewMode>('per-turn');
const [concatFormat, setConcatFormat] = React.useState<ConcatFormat>('plain');
// Fetch execution details
const { data: execution, isLoading, error } = useCliExecutionDetail(
open ? executionId : null,
{ enabled: open }
);
const { data: execution, isLoading } = useCliExecutionDetail(open ? executionId : null);
// Get streaming outputs from store using stable selector
// Use selector factory to prevent infinite re-renders
const selectOutputs = React.useMemo(
() => createOutputsSelector(executionId),
[executionId]
);
const outputs = useCliStreamStore(selectOutputs) || [];
// Build concatenated prompt
const concatenatedPrompt = React.useMemo(() => {
if (!execution?.turns) return '';
return buildConcatenatedPrompt(execution, concatFormat, formatMessage);
}, [execution, concatFormat, formatMessage]);
// Build output lines from conversation (historical) + streaming (real-time)
const allOutputs: CliOutputLine[] = React.useMemo(() => {
const historical: CliOutputLine[] = [];
// Add historical output from conversation turns
if (execution?.turns) {
for (const turn of execution.turns) {
if (turn.output?.stdout) {
historical.push({
type: 'stdout',
content: turn.output.stdout,
timestamp: new Date(turn.timestamp).getTime(),
});
}
if (turn.output?.stderr) {
historical.push({
type: 'stderr',
content: turn.output.stderr,
timestamp: new Date(turn.timestamp).getTime(),
});
}
// Copy to clipboard
const copyToClipboard = React.useCallback(
async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
// Optional: add toast notification here
console.log(`Copied ${label} to clipboard`);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Combine historical + streaming
return [...historical, ...outputs];
}, [execution, outputs]);
},
[]
);
// Calculate total duration
const totalDuration = React.useMemo(() => {
@@ -140,132 +346,112 @@ export function CliStreamPanel({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border">
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
{formatMessage({ id: 'cli.executionDetails' })}
{formatMessage({ id: 'cli-manager.executionDetails' })}
</DialogTitle>
{/* Execution info badges */}
{execution && (
<div className="flex items-center gap-2">
<Badge variant={getToolVariant(execution.tool)}>
<Badge variant={getToolVariant(execution.tool)} title={formatMessage({ id: 'cli.details.tool' })}>
{execution.tool.toUpperCase()}
</Badge>
{execution.mode && (
<Badge variant="secondary">{execution.mode}</Badge>
)}
<span className="text-sm text-muted-foreground">
{execution.mode && <Badge variant="secondary" title={formatMessage({ id: 'cli.details.mode' })}>{execution.mode}</Badge>}
<span className="text-sm text-muted-foreground font-mono" title={formatMessage({ id: 'cli.details.duration' })}>
{formatDuration(totalDuration)}
</span>
</div>
)}
</div>
{execution && (
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.created' })}>
<Calendar className="h-3 w-3" />
{new Date(execution.created_at).toLocaleString()}
</span>
<span className="flex items-center gap-1" title={formatMessage({ id: 'cli.details.id' })}>
<Hash className="h-3 w-3" />
{execution.id.slice(0, 8)}
</span>
<span>{execution.turn_count} {formatMessage({ id: 'cli-manager.streamPanel.turns' })}</span>
</div>
)}
</DialogHeader>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
<div className="text-muted-foreground">{formatMessage({ id: 'cli-manager.streamPanel.loading' })}</div>
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center text-destructive">
Failed to load execution details
</div>
) : execution ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="flex-1 flex flex-col"
>
<div className="px-6 pt-4">
<TabsList>
<TabsTrigger value="prompt">
{formatMessage({ id: 'cli.tabs.prompt' })}
</TabsTrigger>
<TabsTrigger value="output">
{formatMessage({ id: 'cli.tabs.output' })}
</TabsTrigger>
<TabsTrigger value="details">
{formatMessage({ id: 'cli.tabs.details' })}
</TabsTrigger>
</TabsList>
) : execution?.turns && execution.turns.length > 0 ? (
<>
{/* View Toggle - Only show for multi-turn conversations */}
{execution.turns.length > 1 && (
<div className="flex items-center gap-2 px-6 py-3 border-b shrink-0">
<Button
size="sm"
variant={viewMode === 'per-turn' ? 'default' : 'outline'}
onClick={() => setViewMode('per-turn')}
className="h-8"
>
<Layers className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cli-manager.streamPanel.perTurnView' })}
</Button>
<Button
size="sm"
variant={viewMode === 'concatenated' ? 'default' : 'outline'}
onClick={() => setViewMode('concatenated')}
className="h-8"
>
<Copy className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cli-manager.streamPanel.concatenatedView' })}
</Button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{viewMode === 'per-turn' ? (
<PerTurnView turns={execution.turns} />
) : (
<ConcatenatedView
prompt={concatenatedPrompt}
format={concatFormat}
onFormatChange={setConcatFormat}
/>
)}
</div>
<div className="flex-1 overflow-hidden px-6 pb-6">
<TabsContent
value="prompt"
className="mt-4 h-full overflow-y-auto m-0"
{/* Footer Actions */}
<div className="flex items-center gap-2 px-6 py-4 border-t bg-muted/30 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(execution.id, 'ID')}
className="h-8"
>
<div className="p-4 bg-muted rounded-lg max-h-[50vh] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap">
{execution.turns.map((turn, i) => (
<div key={i} className="mb-4">
<div className="text-xs text-muted-foreground mb-1">
Turn {turn.turn}
</div>
<div>{turn.prompt}</div>
</div>
))}
</pre>
</div>
</TabsContent>
<TabsContent
value="output"
className="mt-4 h-full m-0"
>
<div className="h-[50vh] border border-border rounded-lg overflow-hidden">
<StreamingOutput
outputs={allOutputs}
isStreaming={outputs.length > 0}
/>
</div>
</TabsContent>
<TabsContent
value="details"
className="mt-4 h-full overflow-y-auto m-0"
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Tool:</span>
<Badge variant={getToolVariant(execution.tool)}>
{execution.tool}
</Badge>
</div>
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Mode:</span>
<span>{execution.mode || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Duration:</span>
<span>{formatDuration(totalDuration)}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Created:</span>
<span>
{new Date(execution.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="text-sm text-muted-foreground">
ID: {execution.id}
</div>
<div className="text-sm text-muted-foreground">
Turns: {execution.turn_count}
</div>
</div>
</TabsContent>
<Copy className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cli-manager.streamPanel.copyId' })}
</Button>
{execution.turns.length > 1 && viewMode === 'concatenated' && (
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(concatenatedPrompt, 'prompt')}
className="h-8"
>
<Copy className="h-4 w-4 mr-2" />
{formatMessage({ id: 'cli-manager.streamPanel.copyPrompt' })}
</Button>
)}
</div>
</Tabs>
) : null}
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
{formatMessage({ id: 'cli-manager.streamPanel.noDetails' })}
</div>
)}
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,338 @@
// ========================================
// ExplorerToolbar Component
// ========================================
// Toolbar component for File Explorer with search and controls
import { Search, X, ChevronDown, RefreshCw, List, Grid, ChevronRight, ChevronDown as ChevronDownIcon } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import type { RootDirectory } from '@/lib/api';
import type { ExplorerViewMode, ExplorerSortOrder } from '@/types/file-explorer';
export interface ExplorerToolbarProps {
/** Current search query */
searchQuery: string;
/** Callback when search query changes */
onSearchChange: (query: string) => void;
/** Callback when search is cleared */
onSearchClear: () => void;
/** Callback when refresh is requested */
onRefresh: () => void;
/** Available root directories */
rootDirectories: RootDirectory[];
/** Currently selected root directory */
selectedRoot: string;
/** Callback when root directory changes */
onRootChange: (path: string) => void;
/** Loading state for root directories */
isLoadingRoots?: boolean;
/** Current view mode */
viewMode: ExplorerViewMode;
/** Callback when view mode changes */
onViewModeChange: (mode: ExplorerViewMode) => void;
/** Current sort order */
sortOrder: ExplorerSortOrder;
/** Callback when sort order changes */
onSortOrderChange: (order: ExplorerSortOrder) => void;
/** Whether to show hidden files */
showHiddenFiles: boolean;
/** Callback when show hidden files toggles */
onToggleShowHidden: () => void;
/** Callback to expand all directories */
onExpandAll?: () => void;
/** Callback to collapse all directories */
onCollapseAll?: () => void;
/** Custom class name */
className?: string;
}
/**
* Get root directory display name
*/
function getRootDisplayName(root: RootDirectory): string {
if (root.name) return root.name;
const parts = root.path.split(/[/\\]/);
return parts[parts.length - 1] || root.path;
}
/**
* ExplorerToolbar component
*
* @example
* ```tsx
* <ExplorerToolbar
* searchQuery={filter}
* onSearchChange={setFilter}
* onSearchClear={() => setFilter('')}
* onRefresh={refetch}
* rootDirectories={rootDirectories}
* selectedRoot={rootPath}
* onRootChange={(path) => setRootPath(path)}
* viewMode={viewMode}
* onViewModeChange={setViewMode}
* sortOrder={sortOrder}
* onSortOrderChange={setSortOrder}
* showHiddenFiles={showHiddenFiles}
* onToggleShowHidden={toggleShowHidden}
* />
* ```
*/
export function ExplorerToolbar({
searchQuery,
onSearchChange,
onSearchClear,
onRefresh,
rootDirectories,
selectedRoot,
onRootChange,
isLoadingRoots = false,
viewMode,
onViewModeChange,
sortOrder,
onSortOrderChange,
showHiddenFiles,
onToggleShowHidden,
onExpandAll,
onCollapseAll,
className,
}: ExplorerToolbarProps) {
const { formatMessage } = useIntl();
const selectedRootDir = rootDirectories.find((r) => r.path === selectedRoot);
// Handle sort order change
const handleSortOrderChange = (order: ExplorerSortOrder) => {
onSortOrderChange(order);
};
// Handle view mode change
const handleViewModeChange = (mode: ExplorerViewMode) => {
onViewModeChange(mode);
};
return (
<div className={cn('flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/30', className)}>
{/* Root directory selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 max-w-[200px]">
<span className="truncate">
{selectedRootDir
? getRootDisplayName(selectedRootDir)
: formatMessage({ id: 'explorer.toolbar.selectRoot' })
}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>
{formatMessage({ id: 'explorer.toolbar.rootDirectory' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{rootDirectories.map((root) => (
<DropdownMenuItem
key={root.path}
onClick={() => onRootChange(root.path)}
className={cn(
'flex items-center gap-2 cursor-pointer',
selectedRoot === root.path && 'bg-accent'
)}
>
<span className="flex-1 truncate">{getRootDisplayName(root)}</span>
{root.isWorkspace && (
<span className="text-xs text-primary">WS</span>
)}
{root.isGitRoot && (
<span className="text-xs text-success">GIT</span>
)}
</DropdownMenuItem>
))}
{rootDirectories.length === 0 && !isLoadingRoots && (
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'explorer.toolbar.noRoots' })}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Search input */}
<div className="flex-1 max-w-sm relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'explorer.toolbar.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 pr-9 h-8"
/>
{searchQuery && (
<button
onClick={onSearchClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={formatMessage({ id: 'common.actions.clear' })}
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Refresh button */}
<Button
variant="outline"
size="sm"
onClick={onRefresh}
title={formatMessage({ id: 'common.actions.refresh' })}
className="h-8 w-8 p-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* View mode dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
title={formatMessage({ id: 'explorer.toolbar.viewMode' })}
className="h-8 w-8 p-0"
>
{viewMode === 'tree' ? (
<ChevronRight className="h-4 w-4" />
) : viewMode === 'list' ? (
<List className="h-4 w-4" />
) : (
<Grid className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{formatMessage({ id: 'explorer.toolbar.viewMode' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleViewModeChange('tree')}
className={cn('cursor-pointer', viewMode === 'tree' && 'bg-accent')}
>
<ChevronRight className="h-4 w-4 mr-2" />
{formatMessage({ id: 'explorer.viewMode.tree' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleViewModeChange('list')}
className={cn('cursor-pointer', viewMode === 'list' && 'bg-accent')}
>
<List className="h-4 w-4 mr-2" />
{formatMessage({ id: 'explorer.viewMode.list' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleViewModeChange('compact')}
className={cn('cursor-pointer', viewMode === 'compact' && 'bg-accent')}
>
<Grid className="h-4 w-4 mr-2" />
{formatMessage({ id: 'explorer.viewMode.compact' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Sort order dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
title={formatMessage({ id: 'explorer.toolbar.sortBy' })}
className="h-8"
>
{formatMessage({ id: `explorer.sortOrder.${sortOrder}` })}
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{formatMessage({ id: 'explorer.toolbar.sortBy' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleSortOrderChange('name')}
className={cn('cursor-pointer', sortOrder === 'name' && 'bg-accent')}
>
{formatMessage({ id: 'explorer.sortOrder.name' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortOrderChange('size')}
className={cn('cursor-pointer', sortOrder === 'size' && 'bg-accent')}
>
{formatMessage({ id: 'explorer.sortOrder.size' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortOrderChange('modified')}
className={cn('cursor-pointer', sortOrder === 'modified' && 'bg-accent')}
>
{formatMessage({ id: 'explorer.sortOrder.modified' })}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortOrderChange('type')}
className={cn('cursor-pointer', sortOrder === 'type' && 'bg-accent')}
>
{formatMessage({ id: 'explorer.sortOrder.type' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
title={formatMessage({ id: 'explorer.toolbar.moreOptions' })}
className="h-8 w-8 p-0"
>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{formatMessage({ id: 'explorer.toolbar.options' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onToggleShowHidden}
className={cn('cursor-pointer justify-between', showHiddenFiles && 'bg-accent')}
>
<span>{formatMessage({ id: 'explorer.toolbar.showHidden' })}</span>
{showHiddenFiles && <span className="text-primary"></span>}
</DropdownMenuItem>
{onExpandAll && (
<DropdownMenuItem
onClick={onExpandAll}
className="cursor-pointer"
>
{formatMessage({ id: 'explorer.toolbar.expandAll' })}
</DropdownMenuItem>
)}
{onCollapseAll && (
<DropdownMenuItem
onClick={onCollapseAll}
className="cursor-pointer"
>
{formatMessage({ id: 'explorer.toolbar.collapseAll' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default ExplorerToolbar;

View File

@@ -0,0 +1,325 @@
// ========================================
// FilePreview Component
// ========================================
// File content preview with syntax highlighting
import * as React from 'react';
import { File, Copy, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import type { FileContent } from '@/types/file-explorer';
export interface FilePreviewProps {
/** File content to display */
fileContent: FileContent | null | undefined;
/** Loading state */
isLoading?: boolean;
/** Error message */
error?: string | null;
/** Custom class name */
className?: string;
/** Maximum file size to preview in bytes */
maxSize?: number;
/** Whether to show line numbers */
showLineNumbers?: boolean;
}
/**
* Get language display name
*/
function getLanguageDisplayName(language?: string): string {
if (!language) return 'Plain Text';
const languageNames: Record<string, string> = {
'typescript': 'TypeScript',
'tsx': 'TypeScript JSX',
'javascript': 'JavaScript',
'jsx': 'React JSX',
'python': 'Python',
'ruby': 'Ruby',
'go': 'Go',
'rust': 'Rust',
'java': 'Java',
'csharp': 'C#',
'php': 'PHP',
'scala': 'Scala',
'kotlin': 'Kotlin',
'markdown': 'Markdown',
'json': 'JSON',
'yaml': 'YAML',
'xml': 'XML',
'html': 'HTML',
'css': 'CSS',
'scss': 'SCSS',
'less': 'Less',
'sql': 'SQL',
'bash': 'Bash',
'text': 'Plain Text',
};
return languageNames[language] || language.charAt(0).toUpperCase() + language.slice(1);
}
/**
* Get file extension from path
*/
function getFileExtension(path: string): string {
const parts = path.split('.');
return parts.length > 1 ? parts[parts.length - 1] : '';
}
/**
* Check if file is likely binary
*/
function isBinaryFile(path: string): boolean {
const binaryExtensions = [
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'zip', 'tar', 'gz', 'rar', '7z',
'mp3', 'mp4', 'avi', 'mov', 'wav',
'ttf', 'otf', 'woff', 'woff2', 'eot',
'exe', 'dll', 'so', 'dylib',
'class', 'jar', 'war',
'pdb', 'obj', 'o',
];
const ext = getFileExtension(path).toLowerCase();
return binaryExtensions.includes(ext);
}
/**
* Format file size for display
*/
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Truncate content if too large
*/
function truncateContent(content: string, maxLines: number = 1000): string {
const lines = content.split('\n');
if (lines.length <= maxLines) return content;
return lines.slice(0, maxLines).join('\n') + `\n\n... (${lines.length - maxLines} more lines)`;
}
/**
* FilePreview component
*/
export function FilePreview({
fileContent,
isLoading = false,
error = null,
className,
maxSize = 1024 * 1024, // 1MB default
showLineNumbers = true,
}: FilePreviewProps) {
const { formatMessage } = useIntl();
const [copied, setCopied] = React.useState(false);
const contentRef = React.useRef<HTMLPreElement>(null);
// Copy content to clipboard
const handleCopy = async () => {
if (!fileContent?.content) return;
try {
await navigator.clipboard.writeText(fileContent.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy content:', err);
}
};
// Loading state
if (isLoading) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">
{formatMessage({ id: 'explorer.preview.loading' })}
</span>
</div>
);
}
// Error state
if (error) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<AlertCircle className="h-12 w-12 text-destructive mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'explorer.preview.errorTitle' })}
</h3>
<p className="text-xs text-muted-foreground max-w-md">{error}</p>
</div>
);
}
// Empty state
if (!fileContent) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<File className="h-12 w-12 text-muted-foreground mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'explorer.preview.emptyTitle' })}
</h3>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'explorer.preview.emptyMessage' })}
</p>
</div>
);
}
// Check if file is too large
const isTooLarge = maxSize > 0 && (fileContent.size || 0) > maxSize;
const isBinary = isBinaryFile(fileContent.path);
// Binary file warning
if (isBinary) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<File className="h-12 w-12 text-muted-foreground mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'explorer.preview.binaryTitle' })}
</h3>
<p className="text-xs text-muted-foreground mb-4">
{formatMessage({ id: 'explorer.preview.binaryMessage' })}
</p>
<div className="text-xs text-muted-foreground">
<span>{fileContent.path}</span>
{fileContent.size && (
<span className="ml-2">({formatFileSize(fileContent.size)})</span>
)}
</div>
</div>
);
}
// File too large warning
if (isTooLarge) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
<AlertCircle className="h-12 w-12 text-warning mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'explorer.preview.tooLargeTitle' })}
</h3>
<p className="text-xs text-muted-foreground mb-4">
{formatMessage(
{ id: 'explorer.preview.tooLargeMessage' },
{ size: formatFileSize(maxSize) }
)}
</p>
<div className="text-xs text-muted-foreground">
<span>{fileContent.path}</span>
{fileContent.size && (
<span className="ml-2">({formatFileSize(fileContent.size)})</span>
)}
</div>
</div>
);
}
// Get file name and extension
const fileName = fileContent.path.split('/').pop() || '';
const extension = getFileExtension(fileContent.path);
const language = fileContent.language || getLanguageDisplayName(fileContent.language);
const truncatedContent = truncateContent(fileContent.content);
// Split into lines for line numbers
const lines = truncatedContent.split('\n');
return (
<div className={cn('file-preview flex flex-col h-full', className)}>
{/* Preview header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0 flex-1">
<File className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{fileName}</span>
{extension && (
<span className="text-xs text-muted-foreground uppercase">.{extension}</span>
)}
{fileContent.size && (
<span className="text-xs text-muted-foreground">
({formatFileSize(fileContent.size)})
</span>
)}
</div>
<div className="flex items-center gap-2">
{fileContent.language && (
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted">
{language}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleCopy}
title={formatMessage({ id: 'explorer.preview.copy' })}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-auto custom-scrollbar">
<pre
ref={contentRef}
className={cn(
'text-sm p-4 m-0 bg-background',
'font-mono leading-relaxed',
'whitespace-pre-wrap break-words',
'[&_::selection]:bg-primary/20 [&_::selection]:text-primary'
)}
>
{showLineNumbers ? (
<div className="flex">
{/* Line numbers */}
<div className="text-right text-muted-foreground select-none pr-4 border-r border-border mr-4 min-w-[3rem]">
{lines.map((_, i) => (
<div key={i} className="leading-relaxed">
{i + 1}
</div>
))}
</div>
{/* Code content */}
<code className="flex-1">{truncatedContent}</code>
</div>
) : (
<code>{truncatedContent}</code>
)}
</pre>
</div>
{/* Footer with metadata */}
{fileContent.modifiedTime && (
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
{formatMessage(
{ id: 'explorer.preview.lastModified' },
{ time: new Date(fileContent.modifiedTime).toLocaleString() }
)}
</div>
)}
</div>
);
}
export default FilePreview;

View File

@@ -0,0 +1,257 @@
// ========================================
// Graph Sidebar Component
// ========================================
// Sidebar with legend and node details for Graph Explorer
import { useIntl } from 'react-intl';
import { X, Info, Network, FileText, GitBranch, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import type { GraphNode, NodeType, EdgeType } from '@/types/graph-explorer';
export interface GraphSidebarProps {
/** Selected node */
selectedNode: GraphNode | null;
/** Legend visibility */
showLegend?: boolean;
/** On close callback */
onClose: () => void;
}
/**
* Node type legend item
*/
interface LegendItem {
type: NodeType;
label: string;
color: string;
icon: React.ElementType;
}
/**
* Graph sidebar component
*/
export function GraphSidebar({ selectedNode, showLegend = true, onClose }: GraphSidebarProps) {
const { formatMessage } = useIntl();
const legendItems: LegendItem[] = [
{
type: 'component',
label: formatMessage({ id: 'graph.legend.component' }),
color: 'bg-blue-500',
icon: Network,
},
{
type: 'module',
label: formatMessage({ id: 'graph.legend.module' }),
color: 'bg-blue-500',
icon: FileText,
},
{
type: 'class',
label: formatMessage({ id: 'graph.legend.class' }),
color: 'bg-green-500',
icon: GitBranch,
},
{
type: 'function',
label: formatMessage({ id: 'graph.legend.function' }),
color: 'bg-orange-500',
icon: Zap,
},
{
type: 'variable',
label: formatMessage({ id: 'graph.legend.variable' }),
color: 'bg-cyan-500',
icon: Info,
},
];
const edgeLegendItems = [
{
type: 'imports' as EdgeType,
label: formatMessage({ id: 'graph.legend.imports' }),
color: 'stroke-gray-500',
dashArray: '',
},
{
type: 'calls' as EdgeType,
label: formatMessage({ id: 'graph.legend.calls' }),
color: 'stroke-green-500',
dashArray: '',
},
{
type: 'extends' as EdgeType,
label: formatMessage({ id: 'graph.legend.extends' }),
color: 'stroke-purple-500',
dashArray: 'stroke-dasharray',
},
];
return (
<div className="w-80 bg-card border-l border-border flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="font-semibold text-sm">
{selectedNode
? formatMessage({ id: 'graph.sidebar.nodeDetails' })
: formatMessage({ id: 'graph.sidebar.title' })}
</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{/* Node details */}
{selectedNode ? (
<div className="p-4 space-y-4">
{/* Node header */}
<div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline">{selectedNode.type}</Badge>
{selectedNode.data.hasIssues && (
<Badge variant="destructive">
{formatMessage({ id: 'graph.sidebar.hasIssues' })}
</Badge>
)}
</div>
<h3 className="text-lg font-semibold">{selectedNode.data.label}</h3>
</div>
{/* File path */}
{selectedNode.data.filePath && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.filePath' })}
</label>
<p className="text-sm font-mono mt-1 break-all">{selectedNode.data.filePath}</p>
</div>
)}
{/* Line number */}
{selectedNode.data.lineNumber && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.lineNumber' })}
</label>
<p className="text-sm mt-1">{selectedNode.data.lineNumber}</p>
</div>
)}
{/* Category */}
{selectedNode.data.category && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.category' })}
</label>
<p className="text-sm mt-1 capitalize">{selectedNode.data.category}</p>
</div>
)}
{/* Line count */}
{selectedNode.data.lineCount && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.lineCount' })}
</label>
<p className="text-sm mt-1">{selectedNode.data.lineCount} lines</p>
</div>
)}
{/* Documentation */}
{selectedNode.data.documentation && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.documentation' })}
</label>
<p className="text-sm mt-1 text-muted-foreground">{selectedNode.data.documentation}</p>
</div>
)}
{/* Tags */}
{selectedNode.data.tags && selectedNode.data.tags.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.tags' })}
</label>
<div className="flex flex-wrap gap-1 mt-2">
{selectedNode.data.tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Issues */}
{selectedNode.data.issues && selectedNode.data.issues.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.issues' })}
</label>
<ul className="mt-2 space-y-1">
{selectedNode.data.issues.map((issue, idx) => (
<li key={idx} className="text-sm text-red-600 dark:text-red-400">
{issue}
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="p-4 space-y-6">
{/* Node types legend */}
{showLegend && (
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-3">
{formatMessage({ id: 'graph.legend.nodeTypes' })}
</h3>
<div className="space-y-2">
{legendItems.map(item => {
const Icon = item.icon;
return (
<div key={item.type} className="flex items-center gap-2">
<div className={cn('w-4 h-4 rounded', item.color)} />
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{item.label}</span>
</div>
);
})}
</div>
</div>
)}
{/* Edge types legend */}
{showLegend && (
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-3">
{formatMessage({ id: 'graph.legend.edgeTypes' })}
</h3>
<div className="space-y-2">
{edgeLegendItems.map(item => (
<div key={item.type} className="flex items-center gap-2">
<div className={cn('w-8 h-0.5', item.color, item.dashArray)} />
<span className="text-sm">{item.label}</span>
</div>
))}
</div>
</div>
)}
{/* Instructions */}
<div className="p-3 bg-muted rounded-lg">
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'graph.sidebar.instructions' })}
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
// ========================================
// Graph Toolbar Component
// ========================================
// Toolbar with filters and actions for Graph Explorer
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Filter,
Maximize,
RefreshCw,
ChevronDown,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import { Badge } from '@/components/ui/Badge';
import type { GraphFilters, NodeType, EdgeType } from '@/types/graph-explorer';
export interface GraphToolbarProps {
/** Current filters */
filters: GraphFilters;
/** On filters change callback */
onFiltersChange: (filters: GraphFilters) => void;
/** On fit view callback */
onFitView: () => void;
/** On refresh callback */
onRefresh: () => void;
/** On reset filters callback */
onResetFilters: () => void;
/** Node type counts for badges */
nodeTypeCounts?: Partial<Record<NodeType, number>>;
/** Edge type counts for badges */
edgeTypeCounts?: Partial<Record<EdgeType, number>>;
}
/**
* Graph toolbar component
*/
export function GraphToolbar({
filters,
onFiltersChange,
onFitView,
onRefresh,
onResetFilters,
nodeTypeCounts,
edgeTypeCounts,
}: GraphToolbarProps) {
const { formatMessage } = useIntl();
const [localFilters, setLocalFilters] = useState<GraphFilters>(filters);
const nodeTypeLabels: Record<NodeType, string> = {
component: formatMessage({ id: 'graph.nodeTypes.component' }),
module: formatMessage({ id: 'graph.nodeTypes.module' }),
function: formatMessage({ id: 'graph.nodeTypes.function' }),
class: formatMessage({ id: 'graph.nodeTypes.class' }),
interface: formatMessage({ id: 'graph.nodeTypes.interface' }),
variable: formatMessage({ id: 'graph.nodeTypes.variable' }),
file: formatMessage({ id: 'graph.nodeTypes.file' }),
folder: formatMessage({ id: 'graph.nodeTypes.folder' }),
dependency: formatMessage({ id: 'graph.nodeTypes.dependency' }),
api: formatMessage({ id: 'graph.nodeTypes.api' }),
database: formatMessage({ id: 'graph.nodeTypes.database' }),
service: formatMessage({ id: 'graph.nodeTypes.service' }),
hook: formatMessage({ id: 'graph.nodeTypes.hook' }),
utility: formatMessage({ id: 'graph.nodeTypes.utility' }),
unknown: formatMessage({ id: 'graph.nodeTypes.unknown' }),
};
const edgeTypeLabels: Record<EdgeType, string> = {
imports: formatMessage({ id: 'graph.edgeTypes.imports' }),
exports: formatMessage({ id: 'graph.edgeTypes.exports' }),
extends: formatMessage({ id: 'graph.edgeTypes.extends' }),
implements: formatMessage({ id: 'graph.edgeTypes.implements' }),
uses: formatMessage({ id: 'graph.edgeTypes.uses' }),
'depends-on': formatMessage({ id: 'graph.edgeTypes.dependsOn' }),
calls: formatMessage({ id: 'graph.edgeTypes.calls' }),
instantiates: formatMessage({ id: 'graph.edgeTypes.instantiates' }),
contains: formatMessage({ id: 'graph.edgeTypes.contains' }),
'related-to': formatMessage({ id: 'graph.edgeTypes.relatedTo' }),
'data-flow': formatMessage({ id: 'graph.edgeTypes.dataFlow' }),
event: formatMessage({ id: 'graph.edgeTypes.event' }),
unknown: formatMessage({ id: 'graph.edgeTypes.unknown' }),
};
const handleNodeTypeToggle = (nodeType: NodeType) => {
const current = localFilters.nodeTypes || [];
const updated = current.includes(nodeType)
? current.filter(t => t !== nodeType)
: [...current, nodeType];
const newFilters = { ...localFilters, nodeTypes: updated };
setLocalFilters(newFilters);
onFiltersChange(newFilters);
};
const handleEdgeTypeToggle = (edgeType: EdgeType) => {
const current = localFilters.edgeTypes || [];
const updated = current.includes(edgeType)
? current.filter(t => t !== edgeType)
: [...current, edgeType];
const newFilters = { ...localFilters, edgeTypes: updated };
setLocalFilters(newFilters);
onFiltersChange(newFilters);
};
const hasActiveFilters =
(localFilters.nodeTypes && localFilters.nodeTypes.length < Object.keys(nodeTypeLabels).length) ||
(localFilters.edgeTypes && localFilters.edgeTypes.length < Object.keys(edgeTypeLabels).length) ||
localFilters.searchQuery ||
localFilters.showOnlyIssues;
return (
<div className="flex items-center gap-2 p-3 bg-card border-b border-border">
{/* Node types filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="w-4 h-4" />
{formatMessage({ id: 'graph.filters.nodeTypes' })}
<Badge variant="secondary" className="ml-1">
{localFilters.nodeTypes?.length || 0}
</Badge>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>{formatMessage({ id: 'graph.filters.selectNodeTypes' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(nodeTypeLabels).map(([type, label]) => {
const count = nodeTypeCounts?.[type as NodeType] || 0;
const isChecked = localFilters.nodeTypes?.includes(type as NodeType);
return (
<DropdownMenuCheckboxItem
key={type}
checked={isChecked}
onCheckedChange={() => handleNodeTypeToggle(type as NodeType)}
disabled={count === 0}
>
<span className="flex-1">{label}</span>
{count > 0 && (
<Badge variant="outline" className="ml-2 text-xs">
{count}
</Badge>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Edge types filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="w-4 h-4" />
{formatMessage({ id: 'graph.filters.edgeTypes' })}
<Badge variant="secondary" className="ml-1">
{localFilters.edgeTypes?.length || 0}
</Badge>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>{formatMessage({ id: 'graph.filters.selectEdgeTypes' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(edgeTypeLabels).map(([type, label]) => {
const count = edgeTypeCounts?.[type as EdgeType] || 0;
const isChecked = localFilters.edgeTypes?.includes(type as EdgeType);
return (
<DropdownMenuCheckboxItem
key={type}
checked={isChecked}
onCheckedChange={() => handleEdgeTypeToggle(type as EdgeType)}
disabled={count === 0}
>
<span className="flex-1">{label}</span>
{count > 0 && (
<Badge variant="outline" className="ml-2 text-xs">
{count}
</Badge>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Separator */}
<div className="w-px h-6 bg-border" />
{/* Zoom and view controls */}
<Button variant="ghost" size="sm" onClick={onFitView} title={formatMessage({ id: 'graph.actions.fitView' })}>
<Maximize className="w-4 h-4" />
</Button>
{/* Separator */}
<div className="w-px h-6 bg-border" />
{/* Actions */}
<Button variant="ghost" size="sm" onClick={onRefresh} title={formatMessage({ id: 'graph.actions.refresh' })}>
<RefreshCw className="w-4 h-4" />
</Button>
{/* Reset filters button (only show when active filters) */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onResetFilters}
className="text-amber-600 dark:text-amber-400"
title={formatMessage({ id: 'graph.actions.resetFilters' })}
>
<Filter className="w-4 h-4 mr-2" />
{formatMessage({ id: 'graph.actions.reset' })}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
// ========================================
// IndexManager Component
// ========================================
// Component for managing code index with status display and rebuild functionality
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Database, RefreshCw, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { StatCard } from '@/components/shared/StatCard';
import { Badge } from '@/components/ui/Badge';
import { useIndex } from '@/hooks/useIndex';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface IndexManagerProps {
className?: string;
}
// ========== Helper Components ==========
/**
* Progress bar for index rebuild
*/
function IndexProgressBar({ progress, status }: { progress?: number; status: string }) {
const { formatMessage } = useIntl();
if (status !== 'building' || progress === undefined) return null;
return (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'index.status.building' })}
</span>
<span className="font-medium text-foreground">{progress}%</span>
</div>
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
/**
* Status badge component
*/
function IndexStatusBadge({ status }: { status: string }) {
const { formatMessage } = useIntl();
const config: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
idle: { variant: 'secondary', label: formatMessage({ id: 'index.status.idle' }) },
building: { variant: 'default', label: formatMessage({ id: 'index.status.building' }) },
completed: { variant: 'outline', label: formatMessage({ id: 'index.status.completed' }) },
failed: { variant: 'destructive', label: formatMessage({ id: 'index.status.failed' }) },
};
const { variant, label } = config[status] ?? config.idle;
return (
<Badge variant={variant} className="text-xs">
{label}
</Badge>
);
}
// ========== Main Component ==========
/**
* IndexManager component for displaying index status and managing rebuild operations
*
* @example
* ```tsx
* <IndexManager />
* ```
*/
export function IndexManager({ className }: IndexManagerProps) {
const { formatMessage } = useIntl();
const { status, isLoading, rebuildIndex, isRebuilding, rebuildError, refetch } = useIndex();
// Auto-refresh during rebuild
const refetchInterval = status?.status === 'building' ? 2000 : 0;
React.useEffect(() => {
if (status?.status === 'building') {
const interval = setInterval(() => {
refetch();
}, refetchInterval);
return () => clearInterval(interval);
}
}, [status?.status, refetchInterval, refetch]);
// Handle rebuild button click
const handleRebuild = async () => {
try {
await rebuildIndex({ force: false });
} catch (error) {
console.error('[IndexManager] Rebuild failed:', error);
}
};
// Format build time (ms to human readable)
const formatBuildTime = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
};
// Format last updated time
const formatLastUpdated = (isoString: string): string => {
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return formatMessage({ id: 'index.time.justNow' });
if (diffMins < 60) return formatMessage({ id: 'index.time.minutesAgo' }, { value: diffMins });
if (diffHours < 24) return formatMessage({ id: 'index.time.hoursAgo' }, { value: diffHours });
return formatMessage({ id: 'index.time.daysAgo' }, { value: diffDays });
};
return (
<Card className={cn('p-6', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'index.title' })}
</h2>
{status && <IndexStatusBadge status={status.status} />}
</div>
<Button
variant="outline"
size="sm"
onClick={handleRebuild}
disabled={isRebuilding || status?.status === 'building'}
className="h-8"
>
<RefreshCw className={cn('w-4 h-4 mr-1', isRebuilding && 'animate-spin')} />
{formatMessage({ id: 'index.actions.rebuild' })}
</Button>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'index.description' })}
</p>
{/* Error message */}
{rebuildError && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">
{formatMessage({ id: 'index.errors.rebuildFailed' })}
</p>
<p className="text-xs text-destructive/80 mt-1">{rebuildError.message}</p>
</div>
</div>
)}
{/* Status error */}
{status?.error && (
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<p className="text-sm text-destructive">{status.error}</p>
</div>
)}
{/* Progress Bar */}
{status && <IndexProgressBar progress={status.progress} status={status.status} />}
{/* Current file being indexed */}
{status?.currentFile && status.status === 'building' && (
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3 animate-spin" />
<span className="truncate">{status.currentFile}</span>
</div>
)}
{/* Stat Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{/* Total Files */}
<StatCard
title={formatMessage({ id: 'index.stats.totalFiles' })}
value={status?.totalFiles ?? 0}
icon={Database}
variant="primary"
isLoading={isLoading}
description={formatMessage({ id: 'index.stats.totalFilesDesc' })}
/>
{/* Last Updated */}
<StatCard
title={formatMessage({ id: 'index.stats.lastUpdated' })}
value={status?.lastUpdated ? formatLastUpdated(status.lastUpdated) : '-'}
icon={Clock}
variant="info"
isLoading={isLoading}
description={status?.lastUpdated
? new Date(status.lastUpdated).toLocaleString()
: formatMessage({ id: 'index.stats.never' })
}
/>
{/* Build Time */}
<StatCard
title={formatMessage({ id: 'index.stats.buildTime' })}
value={status?.buildTime ? formatBuildTime(status.buildTime) : '-'}
icon={status?.status === 'completed' ? CheckCircle2 : AlertCircle}
variant={status?.status === 'completed' ? 'success' : 'warning'}
isLoading={isLoading}
description={formatMessage({ id: 'index.stats.buildTimeDesc' })}
/>
</div>
</Card>
);
}
export default IndexManager;

View File

@@ -0,0 +1,311 @@
// ========================================
// InsightsPanel Component
// ========================================
// AI insights panel for prompt history analysis
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
Sparkles,
AlertTriangle,
Lightbulb,
Wand2,
Loader2,
RefreshCw,
} from 'lucide-react';
import type { PromptInsight, Pattern, Suggestion } from '@/types/store';
export interface InsightsPanelProps {
/** Available insights */
insights?: PromptInsight[];
/** Detected patterns */
patterns?: Pattern[];
/** AI suggestions */
suggestions?: Suggestion[];
/** Currently selected tool */
selectedTool: 'gemini' | 'qwen' | 'codex';
/** Called when tool selection changes */
onToolChange: (tool: 'gemini' | 'qwen' | 'codex') => void;
/** Called when analyze is triggered */
onAnalyze: () => void;
/** Loading state */
isAnalyzing?: boolean;
/** Optional className */
className?: string;
}
const toolConfig = {
gemini: {
label: 'Gemini',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
},
qwen: {
label: 'Qwen',
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
},
codex: {
label: 'Codex',
color: 'text-green-500',
bgColor: 'bg-green-500/10',
},
};
/**
* InsightCard component for displaying a single insight
*/
function InsightCard({ insight }: { insight: PromptInsight }) {
const { formatMessage } = useIntl();
const typeConfig = {
suggestion: {
icon: Lightbulb,
variant: 'info' as const,
color: 'text-blue-500',
},
optimization: {
icon: Sparkles,
variant: 'success' as const,
color: 'text-green-500',
},
warning: {
icon: AlertTriangle,
variant: 'warning' as const,
color: 'text-orange-500',
},
};
const config = typeConfig[insight.type];
const Icon = config.icon;
return (
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className={cn('flex-shrink-0', config.color)}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">{insight.content}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{Math.round(insight.confidence * 100)}% {formatMessage({ id: 'prompts.insights.confidence' })}
</Badge>
</div>
</div>
</div>
);
}
/**
* PatternCard component for displaying a detected pattern
*/
function PatternCard({ pattern }: { pattern: Pattern }) {
return (
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="flex-shrink-0 text-purple-500">
<Wand2 className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{pattern.name}</p>
<p className="text-xs text-muted-foreground mt-1">{pattern.description}</p>
{pattern.example && (
<code className="block mt-2 text-xs bg-background rounded p-2 overflow-x-auto">
{pattern.example}
</code>
)}
{pattern.severity && (
<Badge variant={pattern.severity === 'error' ? 'destructive' : pattern.severity === 'warning' ? 'warning' : 'secondary'} className="mt-2 text-xs">
{pattern.severity}
</Badge>
)}
</div>
</div>
);
}
/**
* SuggestionCard component for displaying a suggestion
*/
function SuggestionCard({ suggestion }: { suggestion: Suggestion }) {
const { formatMessage } = useIntl();
const typeConfig = {
refactor: { color: 'text-blue-500', label: formatMessage({ id: 'prompts.suggestions.types.refactor' }) },
optimize: { color: 'text-green-500', label: formatMessage({ id: 'prompts.suggestions.types.optimize' }) },
fix: { color: 'text-orange-500', label: formatMessage({ id: 'prompts.suggestions.types.fix' }) },
document: { color: 'text-purple-500', label: formatMessage({ id: 'prompts.suggestions.types.document' }) },
};
const config = typeConfig[suggestion.type];
return (
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className={cn('flex-shrink-0', config.color)}>
<Lightbulb className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">{suggestion.title}</p>
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">{suggestion.description}</p>
{suggestion.code && (
<code className="block mt-2 text-xs bg-background rounded p-2 overflow-x-auto">
{suggestion.code}
</code>
)}
{suggestion.effort && (
<Badge variant="secondary" className="mt-2 text-xs">
{formatMessage({ id: 'prompts.suggestions.effort' })}: {suggestion.effort}
</Badge>
)}
</div>
</div>
);
}
/**
* InsightsPanel component - AI analysis panel for prompt history
*/
export function InsightsPanel({
insights = [],
patterns = [],
suggestions = [],
selectedTool,
onToolChange,
onAnalyze,
isAnalyzing = false,
className,
}: InsightsPanelProps) {
const { formatMessage } = useIntl();
const hasContent = insights.length > 0 || patterns.length > 0 || suggestions.length > 0;
return (
<Card className={cn('flex flex-col h-full', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
{formatMessage({ id: 'prompts.insights.title' })}
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => onAnalyze()}
disabled={isAnalyzing}
className="gap-2"
>
{isAnalyzing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{formatMessage({ id: 'prompts.insights.analyze' })}
</Button>
</div>
{/* Tool selector */}
<div className="flex items-center gap-2 mt-3">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'prompts.insights.selectTool' })}:
</span>
<div className="flex gap-1">
{(Object.keys(toolConfig) as Array<keyof typeof toolConfig>).map((tool) => (
<button
key={tool}
onClick={() => onToolChange(tool)}
className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
selectedTool === tool
? cn(toolConfig[tool].bgColor, toolConfig[tool].color)
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{toolConfig[tool].label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto">
{!hasContent && !isAnalyzing ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<Sparkles className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'prompts.insights.empty.title' })}
</h3>
<p className="text-xs text-muted-foreground max-w-sm">
{formatMessage({ id: 'prompts.insights.empty.message' })}
</p>
</div>
) : (
<div className="space-y-4">
{/* Insights section */}
{insights.length > 0 && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
{formatMessage({ id: 'prompts.insights.sections.insights' })}
</h4>
<div className="space-y-2">
{insights.map((insight) => (
<InsightCard key={insight.id} insight={insight} />
))}
</div>
</div>
)}
{/* Patterns section */}
{patterns.length > 0 && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Wand2 className="h-4 w-4" />
{formatMessage({ id: 'prompts.insights.sections.patterns' })}
</h4>
<div className="space-y-2">
{patterns.map((pattern) => (
<PatternCard key={pattern.id} pattern={pattern} />
))}
</div>
</div>
)}
{/* Suggestions section */}
{suggestions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4" />
{formatMessage({ id: 'prompts.insights.sections.suggestions' })}
</h4>
<div className="space-y-2">
{suggestions.map((suggestion) => (
<SuggestionCard key={suggestion.id} suggestion={suggestion} />
))}
</div>
</div>
)}
{isAnalyzing && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{formatMessage({ id: 'prompts.insights.analyzing' })}
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default InsightsPanel;

View File

@@ -0,0 +1,204 @@
// ========================================
// PromptCard Component
// ========================================
// Card component for displaying prompt history items
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
Copy,
Trash2,
ChevronDown,
ChevronUp,
Clock,
Tag,
Calendar,
} from 'lucide-react';
import type { Prompt } from '@/types/store';
export interface PromptCardProps {
/** Prompt data */
prompt: Prompt;
/** Called when delete action is triggered */
onDelete?: (id: string) => void;
/** Optional className */
className?: string;
/** Disabled state for actions */
actionsDisabled?: boolean;
/** Default expanded state */
defaultExpanded?: boolean;
}
/**
* Format date to readable string
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Format content length
*/
function formatContentLength(length: number): string {
if (length >= 1000) {
return `${(length / 1000).toFixed(1)}k chars`;
}
return `${length} chars`;
}
/**
* PromptCard component for displaying prompt history items
*/
export function PromptCard({
prompt,
onDelete,
className,
actionsDisabled = false,
defaultExpanded = false,
}: PromptCardProps) {
const { formatMessage } = useIntl();
const [expanded, setExpanded] = React.useState(defaultExpanded);
const [copied, setCopied] = React.useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(prompt.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
console.error('Failed to copy prompt');
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete?.(prompt.id);
};
const toggleExpanded = () => {
setExpanded((prev) => !prev);
};
return (
<Card className={cn('transition-all duration-200', className)}>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
{/* Title and metadata */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium text-foreground truncate">
{prompt.title || formatMessage({ id: 'prompts.card.untitled' })}
</h3>
{prompt.category && (
<Badge variant="secondary" className="text-xs">
{prompt.category}
</Badge>
)}
</div>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(prompt.createdAt)}
</span>
<span className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{formatContentLength(prompt.content.length)}
</span>
{prompt.useCount !== undefined && prompt.useCount > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatMessage({ id: 'prompts.card.used' }, { count: prompt.useCount })}
</span>
)}
</div>
{/* Tags */}
{prompt.tags && prompt.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{prompt.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{prompt.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{prompt.tags.length - 3}
</Badge>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleCopy}
disabled={actionsDisabled}
title={formatMessage({ id: 'prompts.actions.copy' })}
>
<Copy className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'prompts.actions.copy' })}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={actionsDisabled}
title={formatMessage({ id: 'prompts.actions.delete' })}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'prompts.actions.delete' })}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={toggleExpanded}
title={expanded ? formatMessage({ id: 'prompts.actions.collapse' }) : formatMessage({ id: 'prompts.actions.expand' })}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<span className="sr-only">{expanded ? 'Collapse' : 'Expand'}</span>
</Button>
</div>
</div>
{copied && (
<p className="text-xs text-success mt-2">
{formatMessage({ id: 'prompts.actions.copied' })}
</p>
)}
</CardHeader>
{/* Expanded content */}
{expanded && (
<CardContent className="px-4 pb-4 pt-0">
<div className="rounded-lg bg-muted/50 p-3">
<pre className="text-sm whitespace-pre-wrap break-words text-foreground">
{prompt.content}
</pre>
</div>
</CardContent>
)}
</Card>
);
}
export default PromptCard;

View File

@@ -0,0 +1,89 @@
// ========================================
// PromptStats Component
// ========================================
// Statistics display for prompt history
import * as React from 'react';
import { useIntl } from 'react-intl';
import { StatCard } from '@/components/shared/StatCard';
import { MessageSquare, FileType, Hash } from 'lucide-react';
export interface PromptStatsProps {
/** Total number of prompts */
totalCount: number;
/** Average prompt length in characters */
avgLength: number;
/** Most common intent/category */
topIntent: string | null;
/** Loading state */
isLoading?: boolean;
}
/**
* PromptStats component - displays prompt history statistics
*
* Shows three key metrics:
* - Total prompts: overall count of stored prompts
* - Average length: mean character count across all prompts
* - Top intent: most frequently used category
*/
export function PromptStats({
totalCount,
avgLength,
topIntent,
isLoading = false,
}: PromptStatsProps) {
const { formatMessage } = useIntl();
// Format average length for display
const formatLength = (length: number): string => {
if (length >= 1000) {
return `${(length / 1000).toFixed(1)}k`;
}
return length.toString();
};
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
<StatCard
title={formatMessage({ id: 'prompts.stats.totalCount' })}
value={totalCount}
icon={MessageSquare}
variant="primary"
isLoading={isLoading}
description={formatMessage({ id: 'prompts.stats.totalCountDesc' })}
/>
<StatCard
title={formatMessage({ id: 'prompts.stats.avgLength' })}
value={formatLength(avgLength)}
icon={FileType}
variant="info"
isLoading={isLoading}
description={formatMessage({ id: 'prompts.stats.avgLengthDesc' })}
/>
<StatCard
title={formatMessage({ id: 'prompts.stats.topIntent' })}
value={topIntent || formatMessage({ id: 'prompts.stats.noIntent' })}
icon={Hash}
variant="success"
isLoading={isLoading}
description={formatMessage({ id: 'prompts.stats.topIntentDesc' })}
/>
</div>
);
}
/**
* Skeleton loader for PromptStats
*/
export function PromptStatsSkeleton() {
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
</div>
);
}
export default PromptStats;

View File

@@ -0,0 +1,229 @@
// ========================================
// RuleCard Component
// ========================================
// Rule card with status badge and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import {
FileCode,
MoreVertical,
Edit,
Trash2,
Folder,
User,
AlertCircle,
AlertTriangle,
Info,
} from 'lucide-react';
import type { Rule } from '@/types/store';
export interface RuleCardProps {
/** Rule data */
rule: Rule;
/** Called when edit action is triggered */
onEdit?: (rule: Rule) => void;
/** Called when delete action is triggered */
onDelete?: (ruleId: string) => void;
/** Called when toggle enabled is triggered */
onToggle?: (ruleId: string, enabled: boolean) => void;
/** Optional className */
className?: string;
/** Show actions dropdown */
showActions?: boolean;
/** Disabled state for actions */
actionsDisabled?: boolean;
}
// Severity variant configuration (without labels for i18n)
const severityVariantConfig: Record<
string,
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ReactNode }
> = {
error: { variant: 'destructive' as const, icon: <AlertCircle className="h-3 w-3" /> },
warning: { variant: 'warning' as const, icon: <AlertTriangle className="h-3 w-3" /> },
info: { variant: 'info' as const, icon: <Info className="h-3 w-3" /> },
};
// Severity label keys for i18n
const severityLabelKeys: Record<string, string> = {
error: 'rules.severity.error',
warning: 'rules.severity.warning',
info: 'rules.severity.info',
};
/**
* RuleCard component for displaying rule information
*/
export function RuleCard({
rule,
onEdit,
onDelete,
onToggle,
className,
showActions = true,
actionsDisabled = false,
}: RuleCardProps) {
const { formatMessage } = useIntl();
const { variant: severityVariant, icon: severityIcon } = severityVariantConfig[rule.severity || 'info'] || {
variant: 'default' as const,
icon: null,
};
const severityLabel = rule.severity
? formatMessage({ id: severityLabelKeys[rule.severity] })
: null;
const locationIcon = rule.location === 'user' ? <User className="h-3 w-3" /> : <Folder className="h-3 w-3" />;
const handleToggle = (enabled: boolean) => {
onToggle?.(rule.id, enabled);
};
const handleAction = (e: React.MouseEvent, action: 'edit' | 'delete') => {
e.stopPropagation();
switch (action) {
case 'edit':
onEdit?.(rule);
break;
case 'delete':
onDelete?.(rule.id);
break;
}
};
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30',
!rule.enabled && 'opacity-60',
className
)}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-card-foreground truncate">
{rule.name}
</h3>
<div className="flex items-center gap-1 text-muted-foreground" title={formatMessage({ id: rule.location === 'user' ? 'rules.location.user' : 'rules.location.project' })}>
{locationIcon}
</div>
</div>
{rule.category && (
<p className="text-xs text-muted-foreground mt-0.5">
{rule.category}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{rule.enabled && rule.severity && (
<Badge variant={severityVariant} className="gap-1">
{severityIcon}
{severityLabel}
</Badge>
)}
<Switch
checked={rule.enabled}
onCheckedChange={handleToggle}
disabled={actionsDisabled}
className="data-[state=checked]:bg-primary"
/>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'rules.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'delete')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'rules.actions.delete' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Description */}
{rule.description && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
{rule.description}
</p>
)}
{/* Meta info */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{rule.pattern && (
<span className="flex items-center gap-1 font-mono">
<FileCode className="h-3.5 w-3.5" />
{rule.pattern}
</span>
)}
{rule.subdirectory && (
<span className="flex items-center gap-1">
<Folder className="h-3.5 w-3.5" />
{rule.subdirectory}
</span>
)}
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for RuleCard
*/
export function RuleCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-5 w-32 rounded bg-muted" />
<div className="mt-1 h-3 w-24 rounded bg-muted" />
</div>
<div className="h-5 w-8 rounded-full bg-muted" />
<div className="h-8 w-8 rounded bg-muted" />
</div>
<div className="mt-3 h-4 w-full rounded bg-muted" />
<div className="mt-2 flex gap-4">
<div className="h-3 w-20 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,479 @@
// ========================================
// RuleDialog Component
// ========================================
// Add/Edit dialog for rule configuration
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
createRule,
updateRule,
type Rule,
type RuleCreateInput,
} from '@/lib/api';
import { rulesKeys } from '@/hooks';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface RuleDialogProps {
mode: 'add' | 'edit';
rule?: Rule;
open: boolean;
onClose: () => void;
onSave?: () => void;
}
interface RuleFormData {
name: string;
description: string;
enabled: boolean;
category: string;
severity: Rule['severity'];
fileName: string;
location: 'project' | 'user';
subdirectory: string;
content: string;
pattern: string;
}
interface FormErrors {
name?: string;
fileName?: string;
content?: string;
location?: string;
}
// ========== Categories ==========
const RULE_CATEGORIES = [
'coding',
'testing',
'security',
'architecture',
'documentation',
'performance',
'workflow',
'tooling',
'general',
];
const SEVERITY_LEVELS: Exclude<Rule['severity'], undefined>[] = ['error', 'warning', 'info'];
// ========== Component ==========
export function RuleDialog({
mode,
rule,
open,
onClose,
onSave,
}: RuleDialogProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
// Form state
const [formData, setFormData] = useState<RuleFormData>({
name: '',
description: '',
enabled: true,
category: 'general',
severity: 'info',
fileName: '',
location: 'project',
subdirectory: '',
content: '',
pattern: '',
});
const [errors, setErrors] = useState<FormErrors>({});
// Initialize form from rule prop (edit mode)
useEffect(() => {
if (rule && mode === 'edit') {
setFormData({
name: rule.name,
description: rule.description || '',
enabled: rule.enabled,
category: rule.category || 'general',
severity: rule.severity || 'info',
fileName: rule.path?.split(/[/\\]/).pop() || `${rule.name.toLowerCase().replace(/\s+/g, '-')}.md`,
location: rule.location || 'project',
subdirectory: rule.subdirectory || '',
content: '',
pattern: rule.pattern || '',
});
} else {
// Reset form for add mode
setFormData({
name: '',
description: '',
enabled: true,
category: 'general',
severity: 'info',
fileName: '',
location: 'project',
subdirectory: '',
content: '',
pattern: '',
});
}
setErrors({});
}, [rule, mode, open]);
// Mutations
const createMutation = useMutation({
mutationFn: (data: RuleCreateInput) => createRule(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
handleClose();
onSave?.();
},
});
const updateMutation = useMutation({
mutationFn: ({ ruleId, config }: { ruleId: string; config: Partial<Rule> }) =>
updateRule(ruleId, config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
handleClose();
onSave?.();
},
});
// Handlers
const handleClose = () => {
setErrors({});
onClose();
};
const handleFieldChange = (
field: keyof RuleFormData,
value: string | boolean | Rule['severity']
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error for this field
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
// Auto-generate fileName from name if not set
useEffect(() => {
if (formData.name && !formData.fileName) {
const generatedFileName = `${formData.name.toLowerCase().replace(/\s+/g, '-')}.md`;
setFormData((prev) => ({ ...prev, fileName: generatedFileName }));
}
}, [formData.name, formData.fileName]);
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// Name required
if (!formData.name.trim()) {
newErrors.name = formatMessage({ id: 'rules.dialog.validation.nameRequired' });
}
// File name required
if (!formData.fileName.trim()) {
newErrors.fileName = formatMessage({ id: 'rules.dialog.validation.fileNameRequired' });
}
// File name must end with .md
if (formData.fileName && !formData.fileName.endsWith('.md')) {
newErrors.fileName = formatMessage({ id: 'rules.dialog.validation.fileNameMd' });
}
// Location required
if (!formData.location) {
newErrors.location = formatMessage({ id: 'rules.dialog.validation.locationRequired' });
}
// Content required for new rules
if (mode === 'add' && !formData.content.trim()) {
newErrors.content = formatMessage({ id: 'rules.dialog.validation.contentRequired' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
if (mode === 'add') {
const input: RuleCreateInput = {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
enabled: formData.enabled,
category: formData.category || undefined,
severity: formData.severity,
fileName: formData.fileName.trim(),
location: formData.location,
subdirectory: formData.subdirectory.trim() || undefined,
content: formData.content.trim(),
pattern: formData.pattern.trim() || undefined,
};
createMutation.mutate(input);
} else {
updateMutation.mutate({
ruleId: rule!.id,
config: {
name: formData.name.trim(),
description: formData.description.trim() || undefined,
enabled: formData.enabled,
category: formData.category || undefined,
severity: formData.severity,
pattern: formData.pattern.trim() || undefined,
},
});
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'add'
? formatMessage({ id: 'rules.dialog.addTitle' })
: formatMessage({ id: 'rules.dialog.editTitle' }, { name: rule?.name })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'rules.dialog.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.name' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={formData.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={formatMessage({ id: 'rules.dialog.form.namePlaceholder' })}
error={!!errors.name}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.description' })}
</label>
<Input
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder={formatMessage({ id: 'rules.dialog.form.descriptionPlaceholder' })}
/>
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.category' })}
</label>
<Select value={formData.category} onValueChange={(v) => handleFieldChange('category', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RULE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Severity */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.severity' })}
</label>
<Select
value={formData.severity}
onValueChange={(v) => handleFieldChange('severity', v as Rule['severity'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_LEVELS.map((sev) => (
<SelectItem key={sev} value={sev}>
{formatMessage({ id: `rules.severity.${sev}` })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* File Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.fileName' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={formData.fileName}
onChange={(e) => handleFieldChange('fileName', e.target.value)}
placeholder="rule-name.md"
error={!!errors.fileName}
/>
{errors.fileName && (
<p className="text-sm text-destructive">{errors.fileName}</p>
)}
</div>
{/* Location */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.location' })}
<span className="text-destructive ml-1">*</span>
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="location"
value="project"
checked={formData.location === 'project'}
onChange={(e) => handleFieldChange('location', e.target.value as 'project' | 'user')}
className="w-4 h-4"
/>
<span className="text-sm">
{formatMessage({ id: 'rules.location.project' })}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="location"
value="user"
checked={formData.location === 'user'}
onChange={(e) => handleFieldChange('location', e.target.value as 'project' | 'user')}
className="w-4 h-4"
/>
<span className="text-sm">
{formatMessage({ id: 'rules.location.user' })}
</span>
</label>
</div>
{errors.location && (
<p className="text-sm text-destructive">{errors.location}</p>
)}
</div>
{/* Subdirectory */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.subdirectory' })}
</label>
<Input
value={formData.subdirectory}
onChange={(e) => handleFieldChange('subdirectory', e.target.value)}
placeholder={formatMessage({ id: 'rules.dialog.form.subdirectoryPlaceholder' })}
/>
</div>
{/* Pattern */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.pattern' })}
</label>
<Input
value={formData.pattern}
onChange={(e) => handleFieldChange('pattern', e.target.value)}
placeholder={formatMessage({ id: 'rules.dialog.form.patternPlaceholder' })}
/>
</div>
{/* Content (only for new rules) */}
{mode === 'add' && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'rules.dialog.form.content' })}
<span className="text-destructive ml-1">*</span>
</label>
<textarea
value={formData.content}
onChange={(e) => handleFieldChange('content', e.target.value)}
placeholder={formatMessage({ id: 'rules.dialog.form.contentPlaceholder' })}
className={cn(
'flex min-h-[150px] 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',
errors.content && 'border-destructive focus-visible:ring-destructive'
)}
/>
{errors.content && (
<p className="text-sm text-destructive">{errors.content}</p>
)}
</div>
)}
{/* Enabled */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={formData.enabled}
onChange={(e) => handleFieldChange('enabled', e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enabled" className="text-sm font-medium text-foreground cursor-pointer">
{formatMessage({ id: 'rules.dialog.form.enabled' })}
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isPending}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
>
{isPending
? formatMessage({ id: 'rules.dialog.actions.saving' })
: formatMessage({ id: 'common.actions.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default RuleDialog;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
@@ -16,6 +17,7 @@ import type { ColorScheme, ThemeMode } from '@/lib/theme';
* - System dark mode detection
*/
export function ThemeSelector() {
const { formatMessage } = useIntl();
const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme();
// Resolved mode is either 'light' or 'dark'
@@ -48,7 +50,7 @@ export function ThemeSelector() {
{/* Color Scheme Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.title.colorScheme' })}
</h3>
<div
className="grid grid-cols-4 gap-3"
@@ -60,7 +62,7 @@ export function ThemeSelector() {
<button
key={scheme.id}
onClick={() => handleSchemeSelect(scheme.id)}
aria-label={`选择${scheme.name}主题`}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })}
aria-selected={colorScheme === scheme.id}
role="radio"
className={`
@@ -81,7 +83,7 @@ export function ThemeSelector() {
/>
{/* Label */}
<span className="text-xs font-medium text-text text-center">
{scheme.name}
{formatMessage({ id: `theme.colorScheme.${scheme.id}` })}
</span>
</button>
))}
@@ -91,7 +93,7 @@ export function ThemeSelector() {
{/* Theme Mode Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.title.themeMode' })}
</h3>
<div
className="grid grid-cols-2 gap-3"
@@ -102,7 +104,7 @@ export function ThemeSelector() {
<button
key={modeOption.id}
onClick={() => handleModeSelect(modeOption.id)}
aria-label={`选择${modeOption.name}模式`}
aria-label={formatMessage({ id: 'theme.select.themeMode' }, { name: formatMessage({ id: `theme.themeMode.${modeOption.id}` }) })}
aria-selected={mode === modeOption.id}
role="radio"
className={`
@@ -121,7 +123,7 @@ export function ThemeSelector() {
</span>
{/* Label */}
<span className="text-sm font-medium text-text">
{modeOption.name}
{formatMessage({ id: `theme.themeMode.${modeOption.id}` })}
</span>
</button>
))}
@@ -131,7 +133,7 @@ export function ThemeSelector() {
{/* Current Theme Display */}
<div className="p-3 rounded-lg bg-surface border border-border">
<p className="text-xs text-text-secondary">
: <span className="font-medium text-text">{getThemeName(colorScheme, mode)}</span>
{formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })}
</p>
</div>
</div>

View File

@@ -0,0 +1,298 @@
// ========================================
// TreeView Component
// ========================================
// Recursive tree view component for file explorer using native HTML details/summary
import * as React from 'react';
import { ChevronRight, File, Folder, FolderOpen, FileCode } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { FileSystemNode } from '@/types/file-explorer';
export interface TreeViewProps {
/** Root nodes of the file tree */
nodes: FileSystemNode[];
/** Set of expanded directory paths */
expandedPaths: Set<string>;
/** Currently selected file path */
selectedPath: string | null;
/** Callback when node is clicked */
onNodeClick?: (node: FileSystemNode) => void;
/** Callback when node is double-clicked */
onNodeDoubleClick?: (node: FileSystemNode) => void;
/** Callback to toggle expanded state */
onToggle?: (path: string) => void;
/** Maximum depth to display (0 = unlimited) */
maxDepth?: number;
/** Current depth level */
depth?: number;
/** Whether to show file icons */
showIcons?: boolean;
/** Whether to show file sizes */
showSizes?: boolean;
/** Custom class name */
className?: string;
}
interface TreeNodeProps {
node: FileSystemNode;
level: number;
expandedPaths: Set<string>;
selectedPath: string | null;
maxDepth?: number;
showIcons?: boolean;
showSizes?: boolean;
onNodeClick?: (node: FileSystemNode) => void;
onNodeDoubleClick?: (node: FileSystemNode) => void;
onToggle?: (path: string) => void;
}
/**
* Get file icon based on file extension
*/
function getFileIcon(fileName: string): React.ElementType {
const ext = fileName.split('.').pop()?.toLowerCase();
const codeExtensions = ['ts', 'tsx', 'js', 'jsx', 'vue', 'svelte', 'py', 'rb', 'go', 'rs', 'java', 'cs', 'php', 'scala', 'kt'];
const configExtensions = ['json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'xml', 'config'];
if (codeExtensions.includes(ext || '')) {
return FileCode;
}
if (configExtensions.includes(ext || '')) {
return FileCode;
}
return File;
}
/**
* Get file size in human-readable format
*/
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)}${units[unitIndex]}`;
}
/**
* TreeNode component - renders a single tree node with children
*/
function TreeNode({
node,
level,
expandedPaths,
selectedPath,
maxDepth = 0,
showIcons = true,
showSizes = false,
onNodeClick,
onNodeDoubleClick,
onToggle,
}: TreeNodeProps) {
const isDirectory = node.type === 'directory';
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPath === node.path;
const hasChildren = isDirectory && node.children && node.children.length > 0;
const shouldShowChildren = isExpanded && hasChildren;
const isAtMaxDepth = maxDepth > 0 && level >= maxDepth;
// Get icon component
let Icon: React.ElementType = File;
if (isDirectory) {
Icon = isExpanded ? FolderOpen : Folder;
} else if (showIcons) {
Icon = getFileIcon(node.name);
}
// Handle click
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onNodeClick?.(node);
// Toggle directories on click
if (isDirectory && hasChildren) {
onToggle?.(node.path);
}
};
// Handle double click
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onNodeDoubleClick?.(node);
};
// Handle key press for accessibility
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick(e as any);
}
};
return (
<div
className={cn(
'tree-node',
isDirectory && 'tree-directory',
isSelected && 'selected'
)}
role="treeitem"
aria-expanded={isDirectory ? isExpanded : undefined}
aria-selected={isSelected}
>
{/* Node content */}
<div
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-sm cursor-pointer transition-colors',
'hover:bg-hover hover:text-foreground',
isSelected && 'bg-primary/10 text-primary',
'text-foreground text-sm'
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
title={node.path}
>
{/* Expand/collapse chevron for directories */}
{isDirectory && (
<ChevronRight
className={cn(
'h-4 w-4 flex-shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
)}
{/* Folder/File icon */}
{showIcons && (
<Icon
className={cn(
'h-4 w-4 flex-shrink-0',
isDirectory
? isExpanded
? 'text-blue-500'
: 'text-blue-400'
: 'text-muted-foreground'
)}
/>
)}
{/* Node name */}
<span className="flex-1 truncate">{node.name}</span>
{/* CLAUDE.md indicator */}
{node.hasClaudeMd && (
<span
className="ml-1 px-1.5 py-0.5 text-[10px] font-semibold rounded bg-purple-500/20 text-purple-500"
title="Contains CLAUDE.md context"
>
MD
</span>
)}
{/* File size */}
{showSizes && !isDirectory && node.size && (
<span className="text-xs text-muted-foreground ml-auto">
{formatFileSize(node.size)}
</span>
)}
</div>
{/* Recursive children */}
{shouldShowChildren && !isAtMaxDepth && node.children && (
<div className="tree-children" role="group">
{node.children.map((child) => (
<TreeNode
key={child.path}
node={child}
level={level + 1}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
maxDepth={maxDepth}
showIcons={showIcons}
showSizes={showSizes}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
/**
* TreeView component - displays file tree with expand/collapse
*
* @example
* ```tsx
* <TreeView
* nodes={fileTree}
* expandedPaths={expandedPaths}
* selectedPath={selectedFile}
* onNodeClick={(node) => console.log('Clicked:', node.path)}
* onToggle={(path) => toggleExpanded(path)}
* showIcons
* showSizes
* />
* ```
*/
export function TreeView({
nodes,
expandedPaths,
selectedPath,
onNodeClick,
onNodeDoubleClick,
onToggle,
maxDepth = 0,
depth = 0,
showIcons = true,
showSizes = false,
className,
}: TreeViewProps) {
if (nodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Folder className="h-12 w-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">No files found</p>
</div>
);
}
return (
<div
className={cn('tree-view', className)}
role="tree"
aria-label="File tree"
>
{nodes.map((node) => (
<TreeNode
key={node.path}
node={node}
level={depth}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
maxDepth={maxDepth}
showIcons={showIcons}
showSizes={showSizes}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onToggle={onToggle}
/>
))}
</div>
);
}
export default TreeView;

View File

@@ -0,0 +1,81 @@
// ========================================
// Shared Components Index
// ========================================
// Centralized exports for all shared components
// Card components
export { SessionCard, SessionCardSkeleton } from './SessionCard';
export type { SessionCardProps } from './SessionCard';
export { ConversationCard } from './ConversationCard';
export type { ConversationCardProps } from './ConversationCard';
export { IssueCard, IssueCardSkeleton } from './IssueCard';
export type { IssueCardProps } from './IssueCard';
export { SkillCard, SkillCardSkeleton } from './SkillCard';
export type { SkillCardProps } from './SkillCard';
export { StatCard, StatCardSkeleton } from './StatCard';
export type { StatCardProps } from './StatCard';
export { RuleCard } from './RuleCard';
export type { RuleCardProps } from './RuleCard';
export { PromptCard } from './PromptCard';
export type { PromptCardProps } from './PromptCard';
// Tree and file explorer components
export { TreeView } from './TreeView';
export type { TreeViewProps } from './TreeView';
export { FilePreview } from './FilePreview';
export type { FilePreviewProps } from './FilePreview';
// Graph visualization components
export { GraphToolbar } from './GraphToolbar';
export type { GraphToolbarProps } from './GraphToolbar';
export { GraphSidebar } from './GraphSidebar';
export type { GraphSidebarProps } from './GraphSidebar';
// Insights and analysis components
export { InsightsPanel } from './InsightsPanel';
export type { InsightsPanelProps } from './InsightsPanel';
export { PromptStats } from './PromptStats';
export type { PromptStatsProps } from './PromptStats';
// Workflow and task components
export { KanbanBoard } from './KanbanBoard';
export type { KanbanBoardProps } from './KanbanBoard';
export { TaskDrawer } from './TaskDrawer';
export type { TaskDrawerProps } from './TaskDrawer';
export { Flowchart } from './Flowchart';
export type { FlowchartProps } from './Flowchart';
// CLI and streaming components
export { CliStreamPanel } from './CliStreamPanel';
export type { CliStreamPanelProps } from './CliStreamPanel';
export { CliStreamMonitor } from './CliStreamMonitor';
export type { CliStreamMonitorProps } from './CliStreamMonitor';
export { StreamingOutput } from './StreamingOutput';
export type { StreamingOutputProps } from './StreamingOutput';
// Dialog components
export { RuleDialog } from './RuleDialog';
export type { RuleDialogProps } from './RuleDialog';
// Tools and utility components
export { ThemeSelector } from './ThemeSelector';
export type { ThemeSelectorProps } from './ThemeSelector';
export { IndexManager } from './IndexManager';
export type { IndexManagerProps } from './IndexManager';
export { ExplorerToolbar } from './ExplorerToolbar';
export type { ExplorerToolbarProps } from './ExplorerToolbar';

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,50 @@
// ========================================
// Switch Component
// ========================================
// Toggle switch for boolean values
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
/** Checked state */
checked?: boolean;
/** Change handler */
onCheckedChange?: (checked: boolean) => void;
}
/**
* Switch component - a stylable toggle switch
*/
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, onChange, disabled = false, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onCheckedChange?.(e.target.checked);
};
return (
<label className={cn('relative inline-flex items-center cursor-pointer', className)}>
<input
type="checkbox"
ref={ref}
checked={checked}
onChange={handleChange}
disabled={disabled}
className="sr-only peer"
{...props}
/>
<div className={cn(
'w-9 h-5 bg-input rounded-full peer peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring peer-focus:ring-offset-2',
'peer-checked:bg-primary peer-checked:after:translate-x-full',
'after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:rounded-full after:h-4 after:w-4 after:transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)} />
</label>
);
}
);
Switch.displayName = 'Switch';
export default Switch;

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: boolean;
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] 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",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -0,0 +1,277 @@
// ========================================
// Workspace Selector Component
// ========================================
// Dropdown for selecting recent workspaces with manual path input dialog
import { useState, useCallback } from 'react';
import { ChevronDown, X } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import { useWorkflowStore } from '@/stores/workflowStore';
export interface WorkspaceSelectorProps {
/** Additional CSS classes */
className?: string;
}
/**
* Truncate path to maximum length with ellipsis prefix
* Shows ".../last/folder" for paths longer than maxChars
*/
function truncatePath(path: string, maxChars: number = 40): string {
if (path.length <= maxChars) {
return path;
}
// For Windows paths: C:\Users\...\folder
// For Unix paths: /home/user/.../folder
const separator = path.includes('\\') ? '\\' : '/';
const parts = path.split(separator);
// Start from the end and build up until we hit the limit
const result: string[] = [];
let currentLength = 3; // Start with '...' length
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i];
if (!part) continue;
const newLength = currentLength + part.length + 1;
if (newLength > maxChars && result.length > 0) {
break;
}
result.unshift(part);
currentLength = newLength;
}
return '...' + separator + result.join(separator);
}
/**
* Workspace selector component
*
* Provides a dropdown menu for selecting from recent workspace paths,
* a manual path input dialog for entering custom paths, and delete buttons
* for removing paths from recent history.
*
* @example
* ```tsx
* <WorkspaceSelector />
* ```
*/
export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore((state) => state.projectPath);
const recentPaths = useWorkflowStore((state) => state.recentPaths);
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
const removeRecentPath = useWorkflowStore((state) => state.removeRecentPath);
// UI state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
const [manualPath, setManualPath] = useState('');
/**
* Handle path selection from dropdown
*/
const handleSelectPath = useCallback(
async (path: string) => {
await switchWorkspace(path);
setIsDropdownOpen(false);
},
[switchWorkspace]
);
/**
* Handle remove path from recent history
*/
const handleRemovePath = useCallback(
async (e: React.MouseEvent, path: string) => {
e.stopPropagation(); // Prevent triggering selection
await removeRecentPath(path);
},
[removeRecentPath]
);
/**
* Handle open browse dialog
*/
const handleBrowseFolder = useCallback(() => {
setIsBrowseOpen(true);
setIsDropdownOpen(false);
}, []);
/**
* Handle manual path submission
*/
const handleManualPathSubmit = useCallback(async () => {
const trimmedPath = manualPath.trim();
if (!trimmedPath) {
return; // TODO: Show validation error
}
await switchWorkspace(trimmedPath);
setIsBrowseOpen(false);
setManualPath('');
}, [manualPath, switchWorkspace]);
/**
* Handle dialog cancel
*/
const handleDialogCancel = useCallback(() => {
setIsBrowseOpen(false);
setManualPath('');
}, []);
/**
* Handle keyboard events in dialog input
*/
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleManualPathSubmit();
}
},
[handleManualPathSubmit]
);
const displayPath = projectPath || formatMessage({ id: 'workspace.selector.noWorkspace' });
const truncatedPath = truncatePath(displayPath, 40);
return (
<>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('gap-2 max-w-[300px]', className)}
aria-label={formatMessage({ id: 'workspace.selector.ariaLabel' })}
>
<span className="truncate" title={displayPath}>
{truncatedPath}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80">
<DropdownMenuLabel>
{formatMessage({ id: 'workspace.selector.recentPaths' })}
</DropdownMenuLabel>
{recentPaths.length > 0 && <DropdownMenuSeparator />}
{recentPaths.length === 0 ? (
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'workspace.selector.noRecentPaths' })}
</div>
) : (
recentPaths.map((path) => {
const isCurrent = path === projectPath;
const truncatedItemPath = truncatePath(path, 50);
return (
<DropdownMenuItem
key={path}
onClick={() => handleSelectPath(path)}
className={cn(
'flex items-center gap-2 cursor-pointer group',
isCurrent && 'bg-accent'
)}
title={path}
>
<span className="flex-1 truncate">{truncatedItemPath}</span>
{/* Delete button for non-current paths */}
{!isCurrent && (
<button
onClick={(e) => handleRemovePath(e, path)}
className="opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground rounded p-0.5 transition-opacity"
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
title={formatMessage({ id: 'workspace.selector.removePath' })}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{isCurrent && (
<span className="text-xs text-primary">
{formatMessage({ id: 'workspace.selector.current' })}
</span>
)}
</DropdownMenuItem>
);
})
)}
{recentPaths.length > 0 && <DropdownMenuSeparator />}
{/* Browse button to open manual path dialog */}
<DropdownMenuItem
onClick={handleBrowseFolder}
className="cursor-pointer"
>
{formatMessage({ id: 'workspace.selector.browse' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Manual path input dialog */}
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'workspace.selector.dialog.title' })}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={manualPath}
onChange={(e) => setManualPath(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={formatMessage({ id: 'workspace.selector.dialog.placeholder' })}
autoFocus
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleDialogCancel}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleManualPathSubmit}
disabled={!manualPath.trim()}
>
{formatMessage({ id: 'common.actions.submit' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default WorkspaceSelector;

View File

@@ -0,0 +1,6 @@
// ========================================
// Workspace Components
// ========================================
export { WorkspaceSelector } from './WorkspaceSelector';
export type { WorkspaceSelectorProps } from './WorkspaceSelector';