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

@@ -157,7 +157,7 @@ When called, you receive:
- **User Context**: Specific requirements, constraints, and expectations from user discussion
- **Output Location**: Directory path for generated analysis files
- **Role Hint** (optional): Suggested role or role selection guidance
- **context-package.json** (CCW Workflow): Artifact paths catalog - use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **context-package.json** : Artifact paths catalog - use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **ASSIGNED_ROLE** (optional): Specific role assignment
- **ANALYSIS_DIMENSIONS** (optional): Role-specific analysis dimensions

View File

@@ -102,7 +102,7 @@ When task JSON contains implementation_approach array:
- L1 (Unit): `*.test.*`, `*.spec.*` in `__tests__/`, `tests/unit/`
- L2 (Integration): `tests/integration/`, `*.integration.test.*`
- L3 (E2E): `tests/e2e/`, `*.e2e.test.*`, `cypress/`, `playwright/`
- **context-package.json** (CCW Workflow): Use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **context-package.json** : Use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- Identify test commands from project configuration
```bash

View File

@@ -157,7 +157,7 @@ When called, you receive:
- **User Context**: Specific requirements, constraints, and expectations from user discussion
- **Output Location**: Directory path for generated analysis files
- **Role Hint** (optional): Suggested role or role selection guidance
- **context-package.json** (CCW Workflow): Artifact paths catalog - use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **context-package.json** : Artifact paths catalog - use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **ASSIGNED_ROLE** (optional): Specific role assignment
- **ANALYSIS_DIMENSIONS** (optional): Role-specific analysis dimensions

View File

@@ -97,7 +97,7 @@ When task JSON contains implementation_approach array:
- L1 (Unit): `*.test.*`, `*.spec.*` in `__tests__/`, `tests/unit/`
- L2 (Integration): `tests/integration/`, `*.integration.test.*`
- L3 (E2E): `tests/e2e/`, `*.e2e.test.*`, `cypress/`, `playwright/`
- **context-package.json** (CCW Workflow): Use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- **context-package.json** : Use Read tool to get context package from `.workflow/active/{session}/.process/context-package.json`
- Identify test commands from project configuration
```bash

View File

@@ -33,23 +33,24 @@ const LOCALES_DIR = join(__dirname, '../src/locales');
const SUPPORTED_LOCALES = ['en', 'zh'] as const;
/**
* Recursively get all translation keys from a nested object
* Recursively get all translation keys and values from a nested object
*/
function flattenObject(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
function flattenObject(obj: Record<string, unknown>, prefix = ''): Map<string, string> {
const map = new Map<string, string>();
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...flattenObject(value as Record<string, unknown>, fullKey));
const nestedMap = flattenObject(value as Record<string, unknown>, fullKey);
nestedMap.forEach((v, k) => map.set(k, v));
} else if (typeof value === 'string') {
keys.push(fullKey);
map.set(fullKey, value);
}
}
return keys;
return map;
}
/**
@@ -66,11 +67,11 @@ function loadJsonFile(filePath: string): Record<string, unknown> {
}
/**
* Get all translation keys for a locale
* Get all translation keys and values for a locale
*/
function getLocaleKeys(locale: string): string[] {
function getLocaleKeys(locale: string): Map<string, string> {
const localeDir = join(LOCALES_DIR, locale);
const keys: string[] = [];
const map = new Map<string, string>();
try {
const files = readdirSync(localeDir).filter((f) => f.endsWith('.json'));
@@ -78,17 +79,27 @@ function getLocaleKeys(locale: string): string[] {
for (const file of files) {
const filePath = join(localeDir, file);
const content = loadJsonFile(filePath);
keys.push(...flattenObject(content));
const flatMap = flattenObject(content);
flatMap.forEach((v, k) => map.set(k, v));
}
} catch (error) {
console.error(`Error reading locale directory for ${locale}:`, error);
}
return keys;
return map;
}
/**
* Compare translation keys between locales
* Check if a value is a non-translatable (numbers, symbols, placeholders only)
*/
function isNonTranslatable(value: string): boolean {
// Check if it's just numbers, symbols, or contains only placeholders like {count}, {name}, etc.
const nonTranslatablePattern = /^[0-9%\$#\-\+\=\[\]{}()\/\\.,:;!?<>|"'\s_@*~`^&]*$/;
return nonTranslatablePattern.test(value) && !/[a-zA-Z\u4e00-\u9fa5]/.test(value);
}
/**
* Compare translation keys and values between locales
*/
function compareTranslations(): ValidationResult {
const result: ValidationResult = {
@@ -99,30 +110,44 @@ function compareTranslations(): ValidationResult {
extraKeys: { en: [], zh: [] },
};
// Get keys for each locale
const enKeys = getLocaleKeys('en');
const zhKeys = getLocaleKeys('zh');
// Get keys and values for each locale
const enMap = getLocaleKeys('en');
const zhMap = getLocaleKeys('zh');
// Sort for comparison
enKeys.sort();
zhKeys.sort();
// Get all unique keys
const allKeys = new Set([...enMap.keys(), ...zhMap.keys()]);
// Find keys missing in Chinese
for (const key of enKeys) {
if (!zhKeys.includes(key)) {
for (const key of enMap.keys()) {
if (!zhMap.has(key)) {
result.missingKeys.zh.push(key);
result.isValid = false;
}
}
// Find keys missing in English
for (const key of zhKeys) {
if (!enKeys.includes(key)) {
for (const key of zhMap.keys()) {
if (!enMap.has(key)) {
result.missingKeys.en.push(key);
result.isValid = false;
}
}
// Check for untranslated values (identical en and zh values)
for (const key of allKeys) {
const enValue = enMap.get(key);
const zhValue = zhMap.get(key);
if (enValue && zhValue && enValue === zhValue) {
// Skip if the value is non-translatable (numbers, symbols, etc.)
if (!isNonTranslatable(enValue)) {
result.warnings.push(
`Untranslated value in zh/ for key "${key}": en="${enValue}" == zh="${zhValue}"`
);
}
}
}
return result;
}
@@ -153,10 +178,19 @@ function displayResults(result: ValidationResult): void {
console.log('');
}
// Display warnings
if (result.warnings.length > 0) {
// Display untranslated values warnings
const untranslatedWarnings = result.warnings.filter(w => w.startsWith('Untranslated value'));
if (untranslatedWarnings.length > 0) {
console.log(`Untranslated values in zh/ (${untranslatedWarnings.length}):`);
untranslatedWarnings.forEach((warning) => console.log(` ⚠️ ${warning}`));
console.log('');
}
// Display other warnings
const otherWarnings = result.warnings.filter(w => !w.startsWith('Untranslated value'));
if (otherWarnings.length > 0) {
console.log('Warnings:');
result.warnings.forEach((warning) => console.log(` ⚠️ ${warning}`));
otherWarnings.forEach((warning) => console.log(` ⚠️ ${warning}`));
console.log('');
}

View File

@@ -6,9 +6,11 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
import { IntlProvider } from 'react-intl';
import { useEffect } from 'react';
import { router } from './router';
import queryClient from './lib/query-client';
import type { Locale } from './lib/i18n';
import { useWorkflowStore } from '@/stores/workflowStore';
interface AppProps {
locale: Locale;
@@ -23,10 +25,36 @@ function App({ locale, messages }: AppProps) {
return (
<IntlProvider locale={locale} messages={messages}>
<QueryClientProvider client={queryClient}>
<QueryInvalidator />
<RouterProvider router={router} />
</QueryClientProvider>
</IntlProvider>
);
}
/**
* Query invalidator component
* Registers callback with workflowStore to invalidate workspace queries on workspace switch
*/
function QueryInvalidator() {
const registerQueryInvalidator = useWorkflowStore((state) => state.registerQueryInvalidator);
useEffect(() => {
// Register callback to invalidate all 'workspace' prefixed queries
const callback = () => {
queryClient.invalidateQueries({
predicate: (query) => {
const queryKey = query.queryKey;
// Check if the first element of the query key is 'workspace'
return Array.isArray(queryKey) && queryKey[0] === 'workspace';
},
});
};
registerQueryInvalidator(callback);
}, [registerQueryInvalidator]);
return null;
}
export default App;

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';

View File

@@ -15,6 +15,11 @@ export type { UseConfigReturn } from './useConfig';
export { useNotifications } from './useNotifications';
export type { UseNotificationsReturn, ToastOptions } from './useNotifications';
export { useWebSocketNotifications } from './useWebSocketNotifications';
export { useSystemNotifications } from './useSystemNotifications';
export type { UseSystemNotificationsReturn, SystemNotificationOptions } from './useSystemNotifications';
export { useDashboardStats, usePrefetchDashboardStats, dashboardStatsKeys } from './useDashboardStats';
export type { UseDashboardStatsOptions, UseDashboardStatsReturn } from './useDashboardStats';
@@ -154,6 +159,8 @@ export {
hooksKeys,
useRules,
useToggleRule,
useCreateRule,
useDeleteRule,
rulesKeys,
} from './useCli';
export type {
@@ -176,3 +183,11 @@ export type {
UseCliExecutionOptions,
UseCliExecutionReturn,
} from './useCliExecution';
// ========== Workspace Query Keys ==========
export {
useWorkspaceQueryKeys,
} from './useWorkspaceQueryKeys';
export type {
WorkspaceQueryKeys,
} from './useWorkspaceQueryKeys';

View File

@@ -0,0 +1,202 @@
// ========================================
// useActiveCliExecutions Hook
// ========================================
// Hook for syncing active CLI executions from server
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCliStreamStore } from '@/stores/cliStreamStore';
/**
* Response type from /api/cli/active endpoint
*/
interface ActiveCliExecution {
id: string;
tool: string;
mode: string;
status: 'running' | 'completed' | 'error';
output?: string;
startTime: number;
isComplete?: boolean;
}
interface ActiveCliExecutionsResponse {
executions: ActiveCliExecution[];
}
/**
* Maximum number of output lines to sync per execution
*/
const MAX_OUTPUT_LINES = 5000;
/**
* Parse message type from content for proper formatting
* Maps Chinese prefixes to output types
*/
function parseMessageType(content: string): { type: 'stdout' | 'stderr' | 'metadata' | 'thought' | 'system' | 'tool_call'; hasPrefix: boolean } {
const patterns = {
system: /^\[系统\]/,
thought: /^\[思考\]/,
response: /^\[响应\]/,
result: /^\[结果\]/,
error: /^\[错误\]/,
warning: /^\[警告\]/,
info: /^\[信息\]/
};
for (const [type, pattern] of Object.entries(patterns)) {
if (pattern.test(content)) {
const typeMap: Record<string, 'stdout' | 'stderr' | 'metadata' | 'thought' | 'system' | 'tool_call'> = {
system: 'system',
thought: 'thought',
response: 'stdout',
result: 'metadata',
error: 'stderr',
warning: 'stderr',
info: 'metadata'
};
return { type: typeMap[type] || 'stdout', hasPrefix: true };
}
}
return { type: 'stdout', hasPrefix: false };
}
/**
* Parse historical output from server response
*/
function parseHistoricalOutput(rawOutput: string, startTime: number) {
if (!rawOutput) return [];
const lines = rawOutput.split('\n');
const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1);
const historicalLines: Array<{ type: 'stdout' | 'stderr' | 'metadata' | 'thought' | 'system' | 'tool_call'; content: string; timestamp: number }> = [];
lines.slice(startIndex).forEach(line => {
if (line.trim()) {
const { type } = parseMessageType(line);
historicalLines.push({
type,
content: line,
timestamp: startTime || Date.now()
});
}
});
return historicalLines;
}
/**
* Query key for active CLI executions
*/
export const ACTIVE_CLI_EXECUTIONS_QUERY_KEY = ['cliActive'];
/**
* Hook to sync active CLI executions from server
*
* @param enabled - Whether the query should be enabled
* @param refetchInterval - Refetch interval in milliseconds (default: 5000)
*
* @example
* ```tsx
* const { data: executions, isLoading } = useActiveCliExecutions(true);
* ```
*/
export function useActiveCliExecutions(
enabled: boolean,
refetchInterval: number = 5000
) {
const upsertExecution = useCliStreamStore(state => state.upsertExecution);
const executions = useCliStreamStore(state => state.executions);
const setCurrentExecution = useCliStreamStore(state => state.setCurrentExecution);
return useQuery({
queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY,
queryFn: async () => {
const response = await fetch('/api/cli/active');
if (!response.ok) {
throw new Error(`Failed to fetch active executions: ${response.statusText}`);
}
const data: ActiveCliExecutionsResponse = await response.json();
// Process executions and sync to store
let hasNewExecution = false;
const now = Date.now();
for (const exec of data.executions) {
const existing = executions[exec.id];
const historicalOutput = parseHistoricalOutput(exec.output || '', exec.startTime);
if (!existing) {
hasNewExecution = true;
}
// Merge existing output with historical output
const existingOutput = existing?.output || [];
const existingContentSet = new Set(existingOutput.map(o => o.content));
const missingLines = historicalOutput.filter(h => !existingContentSet.has(h.content));
// Prepend missing historical lines before existing output
// Skip system start message when prepending
const systemMsgIndex = existingOutput.findIndex(o => o.type === 'system');
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
const mergedOutput = [...existingOutput];
if (missingLines.length > 0) {
mergedOutput.splice(insertIndex, 0, ...missingLines);
}
// Trim if too long
if (mergedOutput.length > MAX_OUTPUT_LINES) {
mergedOutput.splice(0, mergedOutput.length - MAX_OUTPUT_LINES);
}
// Add system message for new executions
let finalOutput = mergedOutput;
if (!existing) {
finalOutput = [
{
type: 'system',
content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`,
timestamp: exec.startTime
},
...mergedOutput
];
}
upsertExecution(exec.id, {
tool: exec.tool || 'cli',
mode: exec.mode || 'analysis',
status: exec.status || 'running',
output: finalOutput,
startTime: exec.startTime || Date.now(),
endTime: exec.status !== 'running' ? now : undefined,
recovered: !existing
});
}
// Set current execution to first running execution if none selected
if (hasNewExecution) {
const runningExec = data.executions.find(e => e.status === 'running');
if (runningExec && !executions[runningExec.id]) {
setCurrentExecution(runningExec.id);
}
}
return data.executions;
},
enabled,
refetchInterval,
staleTime: 2000, // Consider data fresh for 2 seconds
});
}
/**
* Hook to invalidate active CLI executions query
* Use this to trigger a refetch after an execution event
*/
export function useInvalidateActiveCliExecutions() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY });
};
}

View File

@@ -349,8 +349,11 @@ export function useToggleHook() {
import {
fetchRules,
toggleRule,
createRule as createRuleApi,
deleteRule as deleteRuleApi,
type Rule,
type RulesResponse,
type RuleCreateInput,
} from '../lib/api';
export const rulesKeys = {
@@ -446,3 +449,64 @@ export function useToggleRule() {
error: mutation.error,
};
}
export function useCreateRule() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (input: RuleCreateInput) => createRuleApi(input),
onSuccess: (newRule) => {
queryClient.setQueryData<RulesResponse>(rulesKeys.lists(), (old) => {
if (!old) return { rules: [newRule] };
return {
rules: [newRule, ...old.rules],
};
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
},
});
return {
createRule: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export function useDeleteRule() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ ruleId, location }: { ruleId: string; location?: string }) =>
deleteRuleApi(ruleId, location),
onMutate: async ({ ruleId }) => {
await queryClient.cancelQueries({ queryKey: rulesKeys.all });
const previousRules = queryClient.getQueryData<RulesResponse>(rulesKeys.lists());
queryClient.setQueryData<RulesResponse>(rulesKeys.lists(), (old) => {
if (!old) return old;
return {
rules: old.rules.filter((r) => r.id !== ruleId),
};
});
return { previousRules };
},
onError: (_error, _vars, context) => {
if (context?.previousRules) {
queryClient.setQueryData(rulesKeys.lists(), context.previousRules);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: rulesKeys.all });
},
});
return {
deleteRule: (ruleId: string, location?: string) => mutation.mutateAsync({ ruleId, location }),
isDeleting: mutation.isPending,
error: mutation.error,
};
}

View File

@@ -5,6 +5,8 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchDashboardStats, type DashboardStats } from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Query key factory
export const dashboardStatsKeys = {
@@ -64,12 +66,16 @@ export function useDashboardStats(
): UseDashboardStatsReturn {
const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: dashboardStatsKeys.detail(),
queryKey: workspaceQueryKeys.projectOverview(projectPath),
queryFn: fetchDashboardStats,
staleTime,
enabled,
enabled: queryEnabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
@@ -80,7 +86,9 @@ export function useDashboardStats(
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
if (projectPath) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.all(projectPath) });
}
};
return {
@@ -100,12 +108,15 @@ export function useDashboardStats(
*/
export function usePrefetchDashboardStats() {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
return () => {
queryClient.prefetchQuery({
queryKey: dashboardStatsKeys.detail(),
queryFn: fetchDashboardStats,
staleTime: STALE_TIME,
});
if (projectPath) {
queryClient.prefetchQuery({
queryKey: workspaceQueryKeys.projectOverview(projectPath),
queryFn: fetchDashboardStats,
staleTime: STALE_TIME,
});
}
};
}

View File

@@ -0,0 +1,450 @@
// ========================================
// useFileExplorer Hook
// ========================================
// TanStack Query hooks for File Explorer with WebSocket subscription
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, useCallback, useEffect, useRef } from 'react';
import {
fetchFileTree,
fetchFileContent,
fetchRootDirectories,
searchFiles,
type RootDirectory,
type SearchFilesResponse,
} from '../lib/api';
import type { FileSystemNode, FileContent, ExplorerState } from '../types/file-explorer';
// Query key factory
export const fileExplorerKeys = {
all: ['fileExplorer'] as const,
trees: () => [...fileExplorerKeys.all, 'tree'] as const,
tree: (rootPath: string) => [...fileExplorerKeys.trees(), rootPath] as const,
contents: () => [...fileExplorerKeys.all, 'content'] as const,
content: (path: string) => [...fileExplorerKeys.contents(), path] as const,
roots: () => [...fileExplorerKeys.all, 'roots'] as const,
search: (query: string) => [...fileExplorerKeys.all, 'search', query] as const,
};
// Default stale time: 5 minutes for file tree (stable structure)
const TREE_STALE_TIME = 5 * 60 * 1000;
// Default stale time: 10 minutes for file content
const CONTENT_STALE_TIME = 10 * 60 * 1000;
export interface UseFileExplorerOptions {
/** Root directory path (default: '/') */
rootPath?: string;
/** Maximum tree depth (0 = unlimited) */
maxDepth?: number;
/** Include hidden files */
includeHidden?: boolean;
/** File patterns to exclude (glob patterns) */
excludePatterns?: string[];
/** Override default stale time (ms) */
staleTime?: number;
/** Enable/disable the query */
enabled?: boolean;
}
export interface UseFileExplorerReturn {
/** Current explorer state */
state: ExplorerState;
/** Root nodes of the file tree */
rootNodes: FileSystemNode[];
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Error object if query failed */
error: Error | null;
/** Manually refetch file tree */
refetch: () => Promise<void>;
/** Set the selected file path */
setSelectedFile: (path: string | null) => void;
/** Toggle directory expanded state */
toggleExpanded: (path: string) => void;
/** Expand a directory */
expandDirectory: (path: string) => void;
/** Collapse a directory */
collapseDirectory: (path: string) => void;
/** Expand all directories */
expandAll: () => void;
/** Collapse all directories */
collapseAll: () => void;
/** Set view mode */
setViewMode: (mode: ExplorerState['viewMode']) => void;
/** Set sort order */
setSortOrder: (order: ExplorerState['sortOrder']) => void;
/** Toggle hidden files visibility */
toggleShowHidden: () => void;
/** Set filter string */
setFilter: (filter: string) => void;
/** Load file content */
loadFileContent: (path: string) => Promise<FileContent | undefined>;
/** Available root directories */
rootDirectories: RootDirectory[] | undefined;
/** Root directories loading state */
isLoadingRoots: boolean;
/** Search files */
searchFiles: (query: string) => Promise<SearchFilesResponse | undefined>;
/** Search results */
searchResults: SearchFilesResponse | undefined;
/** Is searching */
isSearching: boolean;
/** Clear file content cache */
clearFileCache: (path?: string) => void;
}
/**
* Hook for File Explorer with WebSocket subscription for real-time updates
*
* @example
* ```tsx
* const { rootNodes, state, setSelectedFile, toggleExpanded } = useFileExplorer({
* rootPath: '/src'
* });
* ```
*/
export function useFileExplorer(options: UseFileExplorerOptions = {}): UseFileExplorerReturn {
const {
rootPath = '/',
maxDepth = 5,
includeHidden = false,
excludePatterns,
staleTime,
enabled = true,
} = options;
const queryClient = useQueryClient();
// Explorer state
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [selectedFile, setSelectedFileState] = useState<string | null>(null);
const [viewMode, setViewModeState] = useState<ExplorerState['viewMode']>('tree');
const [sortOrder, setSortOrderState] = useState<ExplorerState['sortOrder']>('name');
const [showHiddenFiles, setShowHiddenFiles] = useState(false);
const [filter, setFilterState] = useState('');
const [searchResults, setSearchResults] = useState<SearchFilesResponse | undefined>();
// Fetch file tree
const treeQuery = useQuery({
queryKey: fileExplorerKeys.tree(rootPath),
queryFn: () => fetchFileTree(rootPath, { maxDepth, includeHidden, excludePatterns }),
staleTime: staleTime ?? TREE_STALE_TIME,
enabled,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
// Fetch root directories
const rootsQuery = useQuery({
queryKey: fileExplorerKeys.roots(),
queryFn: fetchRootDirectories,
staleTime: TREE_STALE_TIME,
enabled,
retry: 1,
});
const rootNodes = treeQuery.data?.rootNodes ?? [];
const rootDirectories = rootsQuery.data;
// Toggle expanded state
const toggleExpanded = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
// Expand directory
const expandDirectory = useCallback((path: string) => {
setExpandedPaths((prev) => new Set([...prev, path]));
}, []);
// Collapse directory
const collapseDirectory = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}, []);
// Expand all directories
const expandAll = useCallback(() => {
const allPaths = new Set<string>();
const collectPaths = (nodes: FileSystemNode[]) => {
for (const node of nodes) {
if (node.type === 'directory') {
allPaths.add(node.path);
if (node.children) {
collectPaths(node.children);
}
}
}
};
collectPaths(rootNodes);
setExpandedPaths(allPaths);
}, [rootNodes]);
// Collapse all directories
const collapseAll = useCallback(() => {
setExpandedPaths(new Set([rootPath]));
}, [rootPath]);
// Set selected file
const setSelectedFile = useCallback((path: string | null) => {
setSelectedFileState(path);
// Add to query cache for quick access
if (path) {
queryClient.prefetchQuery({
queryKey: fileExplorerKeys.content(path),
queryFn: () => fetchFileContent(path),
staleTime: CONTENT_STALE_TIME,
});
}
}, [queryClient]);
// Set view mode
const setViewMode = useCallback((mode: ExplorerState['viewMode']) => {
setViewModeState(mode);
}, []);
// Set sort order
const setSortOrder = useCallback((order: ExplorerState['sortOrder']) => {
setSortOrderState(order);
}, []);
// Toggle hidden files
const toggleShowHidden = useCallback(() => {
setShowHiddenFiles((prev) => !prev);
}, []);
// Set filter
const setFilter = useCallback((value: string) => {
setFilterState(value);
}, []);
// Load file content
const loadFileContent = useCallback(async (path: string) => {
try {
const content = await queryClient.fetchQuery({
queryKey: fileExplorerKeys.content(path),
queryFn: () => fetchFileContent(path),
staleTime: CONTENT_STALE_TIME,
});
return content;
} catch (error) {
console.error(`[useFileExplorer] Failed to load file content: ${path}`, error);
throw error;
}
}, [queryClient]);
// Search files
const searchFilesHandler = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults(undefined);
return undefined;
}
try {
const results = await queryClient.fetchQuery({
queryKey: fileExplorerKeys.search(query),
queryFn: () => searchFiles({ rootPath, query, maxResults: 100 }),
staleTime: 60000, // 1 minute
});
setSearchResults(results);
return results;
} catch (error) {
console.error('[useFileExplorer] Search failed:', error);
throw error;
}
}, [queryClient, rootPath]);
const isSearching = queryClient.isFetching({ queryKey: fileExplorerKeys.all }) > 0;
// Clear file cache
const clearFileCache = useCallback((path?: string) => {
if (path) {
queryClient.removeQueries({ queryKey: fileExplorerKeys.content(path) });
} else {
queryClient.removeQueries({ queryKey: fileExplorerKeys.contents() });
}
}, [queryClient]);
// Refetch
const refetch = async () => {
await treeQuery.refetch();
};
// Build explorer state object
const state: ExplorerState = {
currentPath: rootPath,
selectedFile,
expandedPaths,
fileTree: rootNodes,
viewMode,
sortOrder,
showHiddenFiles,
filter,
isLoading: treeQuery.isLoading,
error: treeQuery.error?.message ?? null,
fileContents: {},
recentFiles: [],
maxRecentFiles: 10,
directoriesFirst: true,
};
return {
state,
rootNodes,
isLoading: treeQuery.isLoading,
isFetching: treeQuery.isFetching,
error: treeQuery.error,
refetch,
setSelectedFile,
toggleExpanded,
expandDirectory,
collapseDirectory,
expandAll,
collapseAll,
setViewMode,
setSortOrder,
toggleShowHidden,
setFilter,
loadFileContent,
rootDirectories,
isLoadingRoots: rootsQuery.isLoading,
searchFiles: searchFilesHandler,
searchResults,
isSearching,
clearFileCache,
};
}
/**
* Hook for file content with caching
*/
export function useFileContent(filePath: string | null, options: {
enabled?: boolean;
staleTime?: number;
} = {}) {
const { enabled = true, staleTime = CONTENT_STALE_TIME } = options;
const query = useQuery({
queryKey: fileExplorerKeys.content(filePath ?? ''),
queryFn: () => fetchFileContent(filePath ?? ''),
staleTime,
enabled: enabled && !!filePath,
retry: 1,
});
return {
content: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: () => query.refetch(),
};
}
/**
* WebSocket hook for real-time file updates
*
* @example
* ```tsx
* const { isConnected } = useFileExplorerWebSocket({
* onFileChanged: (path) => {
* console.log('File changed:', path);
* refetch();
* }
* });
* ```
*/
export interface UseFileExplorerWebSocketOptions {
/** Enable WebSocket connection */
enabled?: boolean;
/** Callback when file changes */
onFileChanged?: (path: string) => void;
/** Callback when directory changes */
onDirectoryChanged?: (path: string) => void;
}
export interface UseFileExplorerWebSocketReturn {
/** WebSocket connection status */
isConnected: boolean;
}
export function useFileExplorerWebSocket(
options: UseFileExplorerWebSocketOptions = {}
): UseFileExplorerWebSocketReturn {
const { enabled = true, onFileChanged, onDirectoryChanged } = options;
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!enabled) return;
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[FileExplorerWS] Connected');
setIsConnected(true);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle file system change events
if (data.type === 'FILE_CHANGED') {
const { path } = data.payload || {};
if (path) {
onFileChanged?.(path);
}
} else if (data.type === 'DIRECTORY_CHANGED') {
const { path } = data.payload || {};
if (path) {
onDirectoryChanged?.(path);
}
}
} catch (error) {
console.error('[FileExplorerWS] Failed to parse message:', error);
}
};
ws.onclose = () => {
console.log('[FileExplorerWS] Disconnected');
setIsConnected(false);
wsRef.current = null;
};
ws.onerror = (error) => {
console.error('[FileExplorerWS] Error:', error);
setIsConnected(false);
};
} catch (error) {
console.error('[FileExplorerWS] Failed to connect:', error);
}
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [enabled, onFileChanged, onDirectoryChanged]);
return { isConnected };
}
export default useFileExplorer;

View File

@@ -0,0 +1,308 @@
// ========================================
// useGraphData Hook
// ========================================
// TanStack Query hooks for Graph Explorer with data transformation
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
fetchGraphDependencies,
fetchGraphImpact,
type GraphDependenciesRequest,
type GraphDependenciesResponse,
type GraphImpactRequest,
type GraphImpactResponse,
} from '../lib/api';
import type {
GraphData,
GraphNode,
GraphEdge,
GraphFilters,
GraphMetadata,
NodeType,
EdgeType,
} from '../types/graph-explorer';
// Query key factory
export const graphKeys = {
all: ['graph'] as const,
dependencies: () => [...graphKeys.all, 'dependencies'] as const,
dependency: (request: GraphDependenciesRequest) => [...graphKeys.dependencies(), request] as const,
impact: (nodeId: string) => [...graphKeys.all, 'impact', nodeId] as const,
};
// Default stale time: 5 minutes (graph data doesn't change frequently)
const STALE_TIME = 5 * 60 * 1000;
export interface UseGraphDataOptions {
/** Override default stale time (ms) */
staleTime?: number;
/** Enable/disable the query */
enabled?: boolean;
/** Root path for analysis */
rootPath?: string;
/** Maximum depth for traversal */
maxDepth?: number;
/** Filter by node types */
nodeTypes?: NodeType[];
/** Filter by edge types */
edgeTypes?: EdgeType[];
}
export interface UseGraphDataReturn {
/** Graph data with nodes and edges */
graphData: GraphData | undefined;
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Error object if query failed */
error: Error | null;
/** Manually refetch data */
refetch: () => Promise<void>;
/** Invalidate and refetch graph data */
invalidate: () => Promise<void>;
/** Apply filters to graph data */
applyFilters: (filters: GraphFilters) => GraphData | undefined;
}
/**
* Transform API response to GraphData format
*/
function transformToGraphData(response: GraphDependenciesResponse): GraphData {
return {
nodes: response.nodes,
edges: response.edges,
metadata: response.metadata,
};
}
/**
* Apply filters to graph data
*/
function filterGraphData(
graphData: GraphData | undefined,
filters: GraphFilters
): GraphData | undefined {
if (!graphData) return undefined;
let filteredNodes = [...graphData.nodes];
let filteredEdges = [...graphData.edges];
// Filter by node types
if (filters.nodeTypes && filters.nodeTypes.length > 0) {
const nodeTypeSet = new Set(filters.nodeTypes);
filteredNodes = filteredNodes.filter(node => node.type && nodeTypeSet.has(node.type));
}
// Filter by edge types
if (filters.edgeTypes && filters.edgeTypes.length > 0) {
const edgeTypeSet = new Set(filters.edgeTypes);
filteredEdges = filteredEdges.filter(edge => edge.data?.edgeType && edgeTypeSet.has(edge.data.edgeType));
}
// Filter by search query
if (filters.searchQuery) {
const query = filters.searchQuery.toLowerCase();
filteredNodes = filteredNodes.filter(node =>
node.data.label.toLowerCase().includes(query) ||
node.data.filePath?.toLowerCase().includes(query)
);
}
// Filter by file path pattern
if (filters.filePathPattern) {
const pattern = new RegExp(filters.filePathPattern, 'i');
filteredNodes = filteredNodes.filter(node =>
node.data.filePath?.match(pattern)
);
}
// Filter by categories
if (filters.categories && filters.categories.length > 0) {
const categorySet = new Set(filters.categories);
filteredNodes = filteredNodes.filter(node =>
node.data.category && categorySet.has(node.data.category)
);
}
// Filter only nodes with issues
if (filters.showOnlyIssues) {
filteredNodes = filteredNodes.filter(node => node.data.hasIssues);
}
// Filter by minimum complexity
if (filters.minComplexity !== undefined) {
filteredNodes = filteredNodes.filter(node => {
// This would require complexity data to be available
// For now, we'll skip this filter
return true;
});
}
// Filter by tags
if (filters.tags && filters.tags.length > 0) {
const tagSet = new Set(filters.tags);
filteredNodes = filteredNodes.filter(node =>
node.data.tags?.some(tag => tagSet.has(tag))
);
}
// Exclude tags
if (filters.excludeTags && filters.excludeTags.length > 0) {
const excludeTagSet = new Set(filters.excludeTags);
filteredNodes = filteredNodes.filter(node =>
!node.data.tags?.some(tag => excludeTagSet.has(tag))
);
}
// Show/hide isolated nodes
if (!filters.showIsolatedNodes) {
const connectedNodeIds = new Set<string>();
filteredEdges.forEach(edge => {
connectedNodeIds.add(edge.source);
connectedNodeIds.add(edge.target);
});
filteredNodes = filteredNodes.filter(node => connectedNodeIds.has(node.id));
}
// Build set of visible node IDs
const visibleNodeIds = new Set(filteredNodes.map(node => node.id));
// Filter edges to only include edges between visible nodes
filteredEdges = filteredEdges.filter(edge =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)
);
// Apply max depth filter (focus on specific node)
if (filters.focusNodeId) {
const focusNode = filteredNodes.find(n => n.id === filters.focusNodeId);
if (focusNode) {
// Collect nodes within max depth
const nodesWithinDepth = new Set<string>([filters.focusNodeId]);
const visited = new Set<string>();
const traverse = (nodeId: string, depth: number) => {
if (depth > (filters.maxDepth || 3)) return;
if (visited.has(nodeId)) return;
visited.add(nodeId);
filteredEdges.forEach(edge => {
if (edge.source === nodeId && !nodesWithinDepth.has(edge.target)) {
nodesWithinDepth.add(edge.target);
traverse(edge.target, depth + 1);
}
if (edge.target === nodeId && !nodesWithinDepth.has(edge.source)) {
nodesWithinDepth.add(edge.source);
traverse(edge.source, depth + 1);
}
});
};
traverse(filters.focusNodeId, 0);
filteredNodes = filteredNodes.filter(node => nodesWithinDepth.has(node.id));
const depthNodeIds = new Set(nodesWithinDepth);
filteredEdges = filteredEdges.filter(edge =>
depthNodeIds.has(edge.source) && depthNodeIds.has(edge.target)
);
}
}
return {
nodes: filteredNodes,
edges: filteredEdges,
metadata: graphData.metadata,
};
}
/**
* Hook for fetching and filtering graph data
*
* @example
* ```tsx
* const { graphData, isLoading, applyFilters } = useGraphData({
* rootPath: '/src',
* maxDepth: 3
* });
*
* // Apply filters
* const filteredData = applyFilters({
* nodeTypes: ['component', 'hook'],
* edgeTypes: ['imports', 'uses']
* });
* ```
*/
export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataReturn {
const {
staleTime = STALE_TIME,
enabled = true,
rootPath,
maxDepth,
nodeTypes,
edgeTypes,
} = options;
const queryClient = useQueryClient();
const request: GraphDependenciesRequest = {
rootPath,
maxDepth,
includeTypes: nodeTypes,
};
const query = useQuery({
queryKey: graphKeys.dependency(request),
queryFn: () => fetchGraphDependencies(request),
staleTime,
enabled,
retry: 2,
select: transformToGraphData,
});
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: graphKeys.all });
};
const applyFilters = (filters: GraphFilters) => {
return filterGraphData(query.data, filters);
};
return {
graphData: query.data,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error as Error | null,
refetch,
invalidate,
applyFilters,
};
}
/**
* Hook for fetching impact analysis for a specific node
*/
export function useGraphImpact(
nodeId: string | null,
options: {
direction?: 'upstream' | 'downstream' | 'both';
maxDepth?: number;
enabled?: boolean;
} = {}
) {
const { direction = 'both', maxDepth = 3, enabled = true } = options;
return useQuery({
queryKey: graphKeys.impact(nodeId || ''),
queryFn: () => {
if (!nodeId) throw new Error('Node ID is required');
return fetchGraphImpact({ nodeId, direction, maxDepth });
},
enabled: enabled && !!nodeId,
staleTime: STALE_TIME,
retry: 1,
});
}

View File

@@ -0,0 +1,143 @@
// ========================================
// useIndex Hook
// ========================================
// TanStack Query hooks for index management with real-time updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchIndexStatus,
rebuildIndex,
type IndexStatus,
type IndexRebuildRequest,
} from '../lib/api';
// ========== Query Keys ==========
export const indexKeys = {
all: ['index'] as const,
status: () => [...indexKeys.all, 'status'] as const,
};
// ========== Stale Time ==========
// Default stale time: 30 seconds (index status updates less frequently)
const STALE_TIME = 30 * 1000;
// ========== Query Hook ==========
export interface UseIndexStatusOptions {
enabled?: boolean;
staleTime?: number;
refetchInterval?: number;
}
export interface UseIndexStatusReturn {
status: IndexStatus | null;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching index status
*
* @example
* ```tsx
* const { status, isLoading, refetch } = useIndexStatus();
* ```
*/
export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexStatusReturn {
const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: indexKeys.status(),
queryFn: fetchIndexStatus,
staleTime,
enabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: indexKeys.all });
};
return {
status: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
// ========== Mutation Hooks ==========
export interface UseRebuildIndexReturn {
rebuildIndex: (request?: IndexRebuildRequest) => Promise<IndexStatus>;
isRebuilding: boolean;
error: Error | null;
}
/**
* Hook for rebuilding index
*
* @example
* ```tsx
* const { rebuildIndex, isRebuilding } = useRebuildIndex();
*
* const handleRebuild = async () => {
* await rebuildIndex({ force: true });
* };
* ```
*/
export function useRebuildIndex(): UseRebuildIndexReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: rebuildIndex,
onSuccess: (updatedStatus) => {
// Update the status query cache
queryClient.setQueryData(indexKeys.status(), updatedStatus);
},
});
return {
rebuildIndex: mutation.mutateAsync,
isRebuilding: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all index operations
*
* @example
* ```tsx
* const {
* status,
* isLoading,
* rebuildIndex,
* isRebuilding,
* } = useIndex();
* ```
*/
export function useIndex() {
const status = useIndexStatus();
const rebuild = useRebuildIndex();
return {
...status,
rebuildIndex: rebuild.rebuildIndex,
isRebuilding: rebuild.isRebuilding,
rebuildError: rebuild.error,
};
}

View File

@@ -14,6 +14,8 @@ import {
type Issue,
type IssuesResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Query key factory
export const issuesKeys = {
@@ -63,23 +65,27 @@ export interface UseIssuesReturn {
* Hook for fetching and filtering issues
*/
export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
const { filter, projectPath, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const issuesQuery = useQuery({
queryKey: issuesKeys.list(filter),
queryKey: workspaceQueryKeys.issuesList(projectPath),
queryFn: () => fetchIssues(projectPath),
staleTime,
enabled,
enabled: queryEnabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
});
const historyQuery = useQuery({
queryKey: issuesKeys.history(),
queryKey: workspaceQueryKeys.issuesHistory(projectPath),
queryFn: () => fetchIssueHistory(projectPath),
staleTime,
enabled: enabled && (filter?.includeHistory ?? false),
enabled: queryEnabled && (filter?.includeHistory ?? false),
retry: 2,
});
@@ -151,7 +157,9 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: issuesKeys.all });
if (projectPath) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issues(projectPath) });
}
};
return {
@@ -173,11 +181,13 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
/**
* Hook for fetching issue queue
*/
export function useIssueQueue(projectPath?: string) {
export function useIssueQueue(): ReturnType<typeof useQuery> {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery({
queryKey: issuesKeys.queue(),
queryKey: projectPath ? workspaceQueryKeys.issueQueue(projectPath) : ['issueQueue', 'no-project'],
queryFn: () => fetchIssueQueue(projectPath),
staleTime: STALE_TIME,
enabled: !!projectPath,
retry: 2,
});
}
@@ -192,16 +202,13 @@ export interface UseCreateIssueReturn {
export function useCreateIssue(): UseCreateIssueReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: createIssue,
onSuccess: (newIssue) => {
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return { issues: [newIssue] };
return {
issues: [newIssue, ...old.issues],
};
});
onSuccess: () => {
// Invalidate issues cache to trigger refetch
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
},
});
@@ -220,17 +227,14 @@ export interface UseUpdateIssueReturn {
export function useUpdateIssue(): UseUpdateIssueReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: ({ issueId, input }: { issueId: string; input: Partial<Issue> }) =>
updateIssue(issueId, input),
onSuccess: (updatedIssue) => {
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return old;
return {
issues: old.issues.map((i) => (i.id === updatedIssue.id ? updatedIssue : i)),
};
});
onSuccess: () => {
// Invalidate issues cache to trigger refetch
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
},
});
@@ -249,29 +253,13 @@ export interface UseDeleteIssueReturn {
export function useDeleteIssue(): UseDeleteIssueReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: deleteIssue,
onMutate: async (issueId) => {
await queryClient.cancelQueries({ queryKey: issuesKeys.all });
const previousIssues = queryClient.getQueryData<IssuesResponse>(issuesKeys.list());
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return old;
return {
issues: old.issues.filter((i) => i.id !== issueId),
};
});
return { previousIssues };
},
onError: (_error, _issueId, context) => {
if (context?.previousIssues) {
queryClient.setQueryData(issuesKeys.list(), context.previousIssues);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: issuesKeys.all });
onSuccess: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] });
},
});

View File

@@ -12,6 +12,8 @@ import {
type CoreMemory,
type MemoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Query key factory
export const memoryKeys = {
@@ -54,12 +56,16 @@ export interface UseMemoryReturn {
export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: memoryKeys.list(filter),
queryKey: workspaceQueryKeys.memoryList(projectPath),
queryFn: fetchMemories,
staleTime,
enabled,
enabled: queryEnabled,
retry: 2,
});
@@ -100,7 +106,9 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: memoryKeys.all });
if (projectPath) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.memory(projectPath) });
}
};
return {
@@ -126,18 +134,13 @@ export interface UseCreateMemoryReturn {
export function useCreateMemory(): UseCreateMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: createMemory,
onSuccess: (newMemory) => {
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return { memories: [newMemory], totalSize: 0, claudeMdCount: 0 };
return {
...old,
memories: [newMemory, ...old.memories],
totalSize: old.totalSize + (newMemory.size ?? 0),
};
});
onSuccess: () => {
// Invalidate memory cache to trigger refetch
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
@@ -156,20 +159,14 @@ export interface UseUpdateMemoryReturn {
export function useUpdateMemory(): UseUpdateMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: ({ memoryId, input }: { memoryId: string; input: Partial<CoreMemory> }) =>
updateMemory(memoryId, input),
onSuccess: (updatedMemory) => {
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return old;
return {
...old,
memories: old.memories.map((m) =>
m.id === updatedMemory.id ? updatedMemory : m
),
};
});
onSuccess: () => {
// Invalidate memory cache to trigger refetch
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
@@ -188,32 +185,13 @@ export interface UseDeleteMemoryReturn {
export function useDeleteMemory(): UseDeleteMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: deleteMemory,
onMutate: async (memoryId) => {
await queryClient.cancelQueries({ queryKey: memoryKeys.all });
const previousMemories = queryClient.getQueryData<MemoryResponse>(memoryKeys.list());
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return old;
const removedMemory = old.memories.find((m) => m.id === memoryId);
return {
...old,
memories: old.memories.filter((m) => m.id !== memoryId),
totalSize: old.totalSize - (removedMemory?.size ?? 0),
};
});
return { previousMemories };
},
onError: (_error, _memoryId, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(memoryKeys.list(), context.previousMemories);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: memoryKeys.all });
onSuccess: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});

View File

@@ -0,0 +1,254 @@
// ========================================
// usePromptHistory Hook
// ========================================
// TanStack Query hooks for prompt history with real-time updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchPrompts,
fetchPromptInsights,
analyzePrompts,
deletePrompt,
type Prompt,
type PromptInsight,
type Pattern,
type Suggestion,
type PromptsResponse,
type PromptInsightsResponse,
} from '../lib/api';
// Query key factory
export const promptHistoryKeys = {
all: ['promptHistory'] as const,
lists: () => [...promptHistoryKeys.all, 'list'] as const,
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
};
// Default stale time: 30 seconds (prompts update less frequently)
const STALE_TIME = 30 * 1000;
export interface PromptHistoryFilter {
search?: string;
intent?: string;
dateRange?: { start: Date | null; end: Date | null };
}
export interface UsePromptHistoryOptions {
filter?: PromptHistoryFilter;
staleTime?: number;
enabled?: boolean;
}
export interface UsePromptHistoryReturn {
prompts: Prompt[];
totalPrompts: number;
promptsBySession: Record<string, Prompt[]>;
stats: {
totalCount: number;
avgLength: number;
topIntent: string | null;
};
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering prompt history
*/
export function usePromptHistory(options: UsePromptHistoryOptions = {}): UsePromptHistoryReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: promptHistoryKeys.list(filter),
queryFn: fetchPrompts,
staleTime,
enabled,
retry: 2,
});
const allPrompts = query.data?.prompts ?? [];
const totalCount = query.data?.total ?? 0;
// Apply filters
const filteredPrompts = (() => {
let prompts = allPrompts;
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
prompts = prompts.filter(
(p) =>
p.title?.toLowerCase().includes(searchLower) ||
p.content.toLowerCase().includes(searchLower) ||
p.tags?.some((t) => t.toLowerCase().includes(searchLower))
);
}
if (filter?.intent) {
prompts = prompts.filter((p) => p.category === filter.intent);
}
if (filter?.dateRange?.start || filter?.dateRange?.end) {
prompts = prompts.filter((p) => {
const date = new Date(p.createdAt);
const start = filter.dateRange?.start;
const end = filter.dateRange?.end;
if (start && date < start) return false;
if (end && date > end) return false;
return true;
});
}
return prompts;
})();
// Group by session for timeline view
const promptsBySession: Record<string, Prompt[]> = {};
for (const prompt of allPrompts) {
const sessionKey = prompt.tags?.find((t) => t.startsWith('session:'))?.replace('session:', '') || 'ungrouped';
if (!promptsBySession[sessionKey]) {
promptsBySession[sessionKey] = [];
}
promptsBySession[sessionKey].push(prompt);
}
// Calculate stats
const avgLength = allPrompts.length > 0
? Math.round(allPrompts.reduce((sum, p) => sum + p.content.length, 0) / allPrompts.length)
: 0;
const intentCounts: Record<string, number> = {};
for (const prompt of allPrompts) {
const category = prompt.category || 'uncategorized';
intentCounts[category] = (intentCounts[category] || 0) + 1;
}
const topIntent = Object.entries(intentCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
};
return {
prompts: filteredPrompts,
totalPrompts: totalCount,
promptsBySession,
stats: {
totalCount: allPrompts.length,
avgLength,
topIntent,
},
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
/**
* Hook for fetching prompt insights
*/
export function usePromptInsights(options: { enabled?: boolean; staleTime?: number } = {}) {
const { enabled = true, staleTime = STALE_TIME } = options;
return useQuery({
queryKey: promptHistoryKeys.insights(),
queryFn: fetchPromptInsights,
staleTime,
enabled,
retry: 2,
});
}
// ========== Mutations ==========
export interface UseAnalyzePromptsReturn {
analyzePrompts: (request?: { tool?: 'gemini' | 'qwen' | 'codex'; limit?: number }) => Promise<PromptInsightsResponse>;
isAnalyzing: boolean;
error: Error | null;
}
export function useAnalyzePrompts(): UseAnalyzePromptsReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: analyzePrompts,
onSuccess: () => {
// Invalidate insights query after analysis
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insights() });
},
});
return {
analyzePrompts: mutation.mutateAsync,
isAnalyzing: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeletePromptReturn {
deletePrompt: (promptId: string) => Promise<void>;
isDeleting: boolean;
error: Error | null;
}
export function useDeletePrompt(): UseDeletePromptReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deletePrompt,
onMutate: async (promptId) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
if (!old) return old;
return {
...old,
prompts: old.prompts.filter((p) => p.id !== promptId),
total: old.total - 1,
};
});
return { previousPrompts };
},
onError: (_error, _promptId, context) => {
if (context?.previousPrompts) {
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
},
});
return {
deletePrompt: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all prompt history mutations
*/
export function usePromptHistoryMutations() {
const analyze = useAnalyzePrompts();
const remove = useDeletePrompt();
return {
analyzePrompts: analyze.analyzePrompts,
deletePrompt: remove.deletePrompt,
isAnalyzing: analyze.isAnalyzing,
isDeleting: remove.isDeleting,
isMutating: analyze.isAnalyzing || remove.isDeleting,
};
}

View File

@@ -16,6 +16,8 @@ import {
} from '../lib/api';
import type { SessionMetadata } from '../types/store';
import { dashboardStatsKeys } from './useDashboardStats';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Query key factory
export const sessionsKeys = {
@@ -80,12 +82,16 @@ export interface UseSessionsReturn {
export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn {
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: sessionsKeys.list(filter),
queryKey: workspaceQueryKeys.sessionsList(projectPath),
queryFn: fetchSessions,
staleTime,
enabled,
enabled: queryEnabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
@@ -130,7 +136,7 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.sessions(projectPath) });
};
return {
@@ -163,14 +169,8 @@ export function useCreateSession(): UseCreateSessionReturn {
const mutation = useMutation({
mutationFn: createSession,
onSuccess: (newSession) => {
// Update sessions cache
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return { activeSessions: [newSession], archivedSessions: [] };
return {
...old,
activeSessions: [newSession, ...old.activeSessions],
};
});
// Invalidate sessions cache to trigger refetch
queryClient.invalidateQueries({ queryKey: ['workspace'] });
// Invalidate dashboard stats
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
@@ -198,19 +198,9 @@ export function useUpdateSession(): UseUpdateSessionReturn {
const mutation = useMutation({
mutationFn: ({ sessionId, input }: { sessionId: string; input: UpdateSessionInput }) =>
updateSession(sessionId, input),
onSuccess: (updatedSession) => {
// Update sessions cache
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
return {
activeSessions: old.activeSessions.map((s) =>
s.session_id === updatedSession.session_id ? updatedSession : s
),
archivedSessions: old.archivedSessions.map((s) =>
s.session_id === updatedSession.session_id ? updatedSession : s
),
};
});
onSuccess: () => {
// Invalidate sessions cache to trigger refetch
queryClient.invalidateQueries({ queryKey: ['workspace'] });
},
});
@@ -235,43 +225,9 @@ export function useArchiveSession(): UseArchiveSessionReturn {
const mutation = useMutation({
mutationFn: archiveSession,
onMutate: async (sessionId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
// Snapshot previous value
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
// Optimistically update
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
const session = old.activeSessions.find((s) => s.session_id === sessionId);
if (!session) return old;
const archivedSession: SessionMetadata = {
...session,
status: 'archived',
location: 'archived',
updated_at: new Date().toISOString(),
};
return {
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
archivedSessions: [archivedSession, ...old.archivedSessions],
};
});
return { previousSessions };
},
onError: (_error, _sessionId, context) => {
// Rollback on error
if (context?.previousSessions) {
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
}
},
onSettled: () => {
onSuccess: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
queryClient.invalidateQueries({ queryKey: ['workspace'] });
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
});
@@ -297,33 +253,9 @@ export function useDeleteSession(): UseDeleteSessionReturn {
const mutation = useMutation({
mutationFn: deleteSession,
onMutate: async (sessionId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
// Snapshot previous value
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
// Optimistically remove
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
return {
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
archivedSessions: old.archivedSessions.filter((s) => s.session_id !== sessionId),
};
});
return { previousSessions };
},
onError: (_error, _sessionId, context) => {
// Rollback on error
if (context?.previousSessions) {
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
}
},
onSettled: () => {
onSuccess: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
queryClient.invalidateQueries({ queryKey: ['workspace'] });
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
});

View File

@@ -10,6 +10,8 @@ import {
type Skill,
type SkillsResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
// Query key factory
export const skillsKeys = {
@@ -54,12 +56,16 @@ export interface UseSkillsReturn {
export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
// Only enable query when projectPath is available
const queryEnabled = enabled && !!projectPath;
const query = useQuery({
queryKey: skillsKeys.list(filter),
queryKey: workspaceQueryKeys.skillsList(projectPath),
queryFn: fetchSkills,
staleTime,
enabled,
enabled: queryEnabled,
retry: 2,
});
@@ -114,7 +120,9 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: skillsKeys.all });
if (projectPath) {
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.skills(projectPath) });
}
};
return {
@@ -142,33 +150,14 @@ export interface UseToggleSkillReturn {
export function useToggleSkill(): UseToggleSkillReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: ({ skillName, enabled }: { skillName: string; enabled: boolean }) =>
toggleSkill(skillName, enabled),
onMutate: async ({ skillName, enabled }) => {
await queryClient.cancelQueries({ queryKey: skillsKeys.all });
const previousSkills = queryClient.getQueryData<SkillsResponse>(skillsKeys.list());
// Optimistic update
queryClient.setQueryData<SkillsResponse>(skillsKeys.list(), (old) => {
if (!old) return old;
return {
skills: old.skills.map((s) =>
s.name === skillName ? { ...s, enabled } : s
),
};
});
return { previousSkills };
},
onError: (_error, _vars, context) => {
if (context?.previousSkills) {
queryClient.setQueryData(skillsKeys.list(), context.previousSkills);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: skillsKeys.all });
onSuccess: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.skills(projectPath) : ['skills'] });
},
});

View File

@@ -0,0 +1,206 @@
// ========================================
// useSystemNotifications Hook
// ========================================
// Browser native notification support with permission handling,
// localStorage preference persistence, icon/badge display,
// click-to-focus behavior, and 5-second auto-close
import { useState, useEffect, useCallback } from 'react';
// Local storage key for system notifications preference
const SYSTEM_NOTIFICATIONS_ENABLED_KEY = 'ccw_system_notifications_enabled';
// Auto-close timeout for native notifications (ms)
const NOTIFICATION_AUTO_CLOSE_MS = 5000;
/**
* System notification options
*/
export interface SystemNotificationOptions {
title: string;
body?: string;
icon?: string;
badge?: string;
tag?: string;
requireInteraction?: boolean;
}
/**
* Return type for useSystemNotifications hook
*/
export interface UseSystemNotificationsReturn {
enabled: boolean;
permission: NotificationPermission;
toggleEnabled: () => Promise<void>;
requestPermission: () => Promise<boolean>;
showNotification: (options: SystemNotificationOptions) => void;
}
/**
* Check if Notification API is supported
*/
function isNotificationSupported(): boolean {
return typeof window !== 'undefined' && 'Notification' in window;
}
/**
* Load system notifications enabled preference from localStorage
*/
function loadEnabledPreference(): boolean {
if (typeof window === 'undefined') return false;
try {
const saved = localStorage.getItem(SYSTEM_NOTIFICATIONS_ENABLED_KEY);
return saved === 'true';
} catch {
console.warn('[useSystemNotifications] Failed to load preference from localStorage');
return false;
}
}
/**
* Save system notifications enabled preference to localStorage
*/
function saveEnabledPreference(enabled: boolean): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(SYSTEM_NOTIFICATIONS_ENABLED_KEY, String(enabled));
} catch {
console.warn('[useSystemNotifications] Failed to save preference to localStorage');
}
}
/**
* Hook for browser native notification support
*
* Features:
* - Permission handling with browser dialog
* - localStorage preference persistence
* - Icon/badge display
* - Click-to-focus window behavior
* - 5-second auto-close
* - Graceful handling when API unavailable
*
* @returns Object with enabled state, permission status, and control functions
*/
export function useSystemNotifications(): UseSystemNotificationsReturn {
const [enabled, setEnabled] = useState<boolean>(() => loadEnabledPreference());
const [permission, setPermission] = useState<NotificationPermission>(() => {
if (!isNotificationSupported()) return 'denied';
return Notification.permission;
});
// Sync permission state with window.Notification
useEffect(() => {
if (!isNotificationSupported()) return;
const checkPermission = () => {
setPermission(Notification.permission);
};
checkPermission();
// Listen for permission changes (some browsers support this)
window.addEventListener('notificationpermissionchange', checkPermission);
return () => {
window.removeEventListener('notificationpermissionchange', checkPermission);
};
}, []);
/**
* Request browser notification permission
* Prompts user with browser permission dialog
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
if (!isNotificationSupported()) {
console.warn('[useSystemNotifications] Notification API not supported');
return false;
}
if (Notification.permission === 'granted') {
setPermission('granted');
return true;
}
if (Notification.permission === 'denied') {
setPermission('denied');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (error) {
console.warn('[useSystemNotifications] Failed to request permission:', error);
return false;
}
}, []);
/**
* Toggle system notifications enabled state
* Requests permission if not granted when enabling
*/
const toggleEnabled = useCallback(async (): Promise<void> => {
if (enabled) {
// Disabling - just update preference
const newState = false;
setEnabled(newState);
saveEnabledPreference(newState);
} else {
// Enabling - request permission first
const granted = await requestPermission();
if (granted) {
const newState = true;
setEnabled(newState);
saveEnabledPreference(newState);
}
}
}, [enabled, requestPermission]);
/**
* Show a native browser notification
* Only shows if enabled and permission granted
*/
const showNotification = useCallback((options: SystemNotificationOptions) => {
if (!enabled) return;
if (!isNotificationSupported()) return;
if (permission !== 'granted') return;
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || '/favicon.ico',
badge: options.badge || '/favicon.ico',
tag: options.tag || `ccw-notif-${Date.now()}`,
requireInteraction: options.requireInteraction || false,
});
// Click handler: focus window and close notification
notification.onclick = () => {
window.focus();
notification.close();
};
// Auto-close after 5 seconds (unless requireInteraction is true)
if (!options.requireInteraction) {
setTimeout(() => {
notification.close();
}, NOTIFICATION_AUTO_CLOSE_MS);
}
} catch (error) {
console.warn('[useSystemNotifications] Failed to show notification:', error);
}
}, [enabled, permission]);
return {
enabled,
permission,
toggleEnabled,
requestPermission,
showNotification,
};
}
export default useSystemNotifications;

View File

@@ -13,6 +13,7 @@ import {
type OrchestratorWebSocketMessage,
type ExecutionLog,
} from '../types/execution';
import { SurfaceUpdateSchema } from '../packages/a2ui-runtime/core/A2UITypes';
// Constants
const RECONNECT_DELAY_BASE = 1000; // 1 second
@@ -42,6 +43,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
const setWsLastMessage = useNotificationStore((state) => state.setWsLastMessage);
const incrementReconnectAttempts = useNotificationStore((state) => state.incrementReconnectAttempts);
const resetReconnectAttempts = useNotificationStore((state) => state.resetReconnectAttempts);
const addA2UINotification = useNotificationStore((state) => state.addA2UINotification);
// Execution store for state updates
const setExecutionStatus = useExecutionStore((state) => state.setExecutionStatus);
@@ -130,6 +132,17 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
return;
}
// Handle A2UI surface messages
if (data.type === 'a2ui-surface') {
const parsed = SurfaceUpdateSchema.safeParse(data.payload);
if (parsed.success) {
addA2UINotification(parsed.data, 'Interactive UI');
} else {
console.warn('[WebSocket] Invalid A2UI surface:', parsed.error.issues);
}
return;
}
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;

View File

@@ -0,0 +1,156 @@
// ========================================
// useWebSocketNotifications Hook
// ========================================
// Watches wsLastMessage from notificationStore and maps WebSocket events
// to persistent notifications for the notification panel
import { useEffect } from 'react';
import { useNotificationStore } from '@/stores';
import type { WebSocketMessage } from '@/types/store';
// WebSocket message types that should create persistent notifications
type NotificationEventType =
| 'SESSION_CREATED'
| 'TASK_COMPLETED'
| 'TASK_FAILED'
| 'CLI_EXECUTION_STARTED'
| 'CLI_EXECUTION_COMPLETED'
| 'MEMORY_UPDATED';
interface SessionCreatedPayload {
sessionId: string;
title?: string;
}
interface TaskEventPayload {
sessionId?: string;
taskId?: string;
summary?: string;
error?: string;
}
interface CliExecutionPayload {
executionId: string;
tool: string;
duration?: number;
}
interface MemoryUpdatedPayload {
memoryId?: string;
operation?: string;
}
export function useWebSocketNotifications(): void {
const wsLastMessage = useNotificationStore((state) => state.wsLastMessage);
const setWsLastMessage = useNotificationStore((state) => state.setWsLastMessage);
const addPersistentNotification = useNotificationStore(
(state) => state.addPersistentNotification
);
useEffect(() => {
// Only process when we have a message
if (!wsLastMessage) {
return;
}
const { type, payload } = wsLastMessage as WebSocketMessage & {
payload?: unknown;
};
// Route message type to appropriate notification
switch (type as NotificationEventType) {
case 'SESSION_CREATED': {
const data = payload as SessionCreatedPayload | undefined;
const sessionId = data?.sessionId || 'unknown';
const title = data?.title ? `"${data.title}"` : '';
addPersistentNotification({
type: 'info',
title: 'Session Created',
message: `New session ${title}created (${sessionId})`,
read: false,
});
break;
}
case 'TASK_COMPLETED': {
const data = payload as TaskEventPayload | undefined;
const summary = data?.summary || 'Task completed successfully';
const taskId = data?.taskId;
addPersistentNotification({
type: 'success',
title: 'Task Completed',
message: taskId ? `${summary} (${taskId})` : summary,
read: false,
});
break;
}
case 'TASK_FAILED': {
const data = payload as TaskEventPayload | undefined;
const error = data?.error || 'Task execution failed';
const taskId = data?.taskId;
addPersistentNotification({
type: 'error',
title: 'Task Failed',
message: taskId ? `${error} (${taskId})` : error,
duration: 0, // Errors don't auto-dismiss
read: false,
});
break;
}
case 'CLI_EXECUTION_STARTED': {
const data = payload as CliExecutionPayload | undefined;
const tool = data?.tool || 'CLI';
addPersistentNotification({
type: 'info',
title: 'CLI Execution Started',
message: `${tool} execution started`,
read: false,
});
break;
}
case 'CLI_EXECUTION_COMPLETED': {
const data = payload as CliExecutionPayload | undefined;
const tool = data?.tool || 'CLI';
const duration = data?.duration;
const durationText = duration ? ` (${duration}ms)` : '';
addPersistentNotification({
type: 'success',
title: 'CLI Execution Completed',
message: `${tool} execution completed${durationText}`,
read: false,
});
break;
}
case 'MEMORY_UPDATED': {
const data = payload as MemoryUpdatedPayload | undefined;
const operation = data?.operation || 'update';
addPersistentNotification({
type: 'info',
title: 'Memory Updated',
message: `Memory ${operation} completed`,
read: false,
});
break;
}
default:
// Unknown message type - ignore
break;
}
// Clear the message after processing to prevent duplicate handling
setWsLastMessage(null);
}, [wsLastMessage, addPersistentNotification, setWsLastMessage]);
}
export default useWebSocketNotifications;

View File

@@ -0,0 +1,100 @@
// ========================================
// useWorkspaceQueryKeys Hook
// ========================================
// Returns workspace-aware query keys factory with current projectPath
import { useMemo } from 'react';
import { useWorkflowStore } from '../stores/workflowStore';
import { selectProjectPath } from '../stores/workflowStore';
import { workspaceQueryKeys } from '../lib/queryKeys';
/**
* Hook that returns workspace-aware query keys factory
* All keys are memoized and update when projectPath changes
*
* @example
* ```tsx
* const queryKeys = useWorkspaceQueryKeys();
* const { data } = useQuery({
* queryKey: queryKeys.sessionsList(),
* queryFn: fetchSessions,
* });
* ```
*/
export function useWorkspaceQueryKeys() {
const projectPath = useWorkflowStore(selectProjectPath);
// Memoize all key factory functions to recreate only when projectPath changes
const keys = useMemo(() => {
const pk = projectPath || '';
return {
// Base keys
all: workspaceQueryKeys.all(pk),
// Sessions
sessionsList: workspaceQueryKeys.sessionsList(pk),
sessionDetail: (sessionId: string) => workspaceQueryKeys.sessionDetail(pk, sessionId),
// Tasks
tasksList: (sessionId: string) => workspaceQueryKeys.tasksList(pk, sessionId),
taskDetail: (taskId: string) => workspaceQueryKeys.taskDetail(pk, taskId),
// Loops
loopsList: workspaceQueryKeys.loopsList(pk),
loopDetail: (loopId: string) => workspaceQueryKeys.loopDetail(pk, loopId),
// Issues
issuesList: workspaceQueryKeys.issuesList(pk),
issuesHistory: workspaceQueryKeys.issuesHistory(pk),
issueQueue: workspaceQueryKeys.issueQueue(pk),
// Memory
memoryList: workspaceQueryKeys.memoryList(pk),
memoryDetail: (memoryId: string) => workspaceQueryKeys.memoryDetail(pk, memoryId),
// Project Overview
projectOverviewDetail: workspaceQueryKeys.projectOverviewDetail(pk),
// Lite Tasks
liteTasksList: (type?: 'lite-plan' | 'lite-fix' | 'multi-cli-plan') =>
workspaceQueryKeys.liteTasksList(pk, type),
liteTaskDetail: (sessionId: string) => workspaceQueryKeys.liteTaskDetail(pk, sessionId),
// Review Sessions
reviewSessionsList: workspaceQueryKeys.reviewSessionsList(pk),
reviewSessionDetail: (sessionId: string) => workspaceQueryKeys.reviewSessionDetail(pk, sessionId),
// Rules
rulesList: workspaceQueryKeys.rulesList(pk),
// Prompts
promptsList: workspaceQueryKeys.promptsList(pk),
promptsInsights: workspaceQueryKeys.promptsInsights(pk),
// Index
indexStatus: workspaceQueryKeys.indexStatus(pk),
// File Explorer
explorerTree: (rootPath?: string) => workspaceQueryKeys.explorerTree(pk, rootPath),
explorerFile: (filePath?: string) => workspaceQueryKeys.explorerFile(pk, filePath),
// Graph Explorer
graphDependencies: (options?: { maxDepth?: number }) =>
workspaceQueryKeys.graphDependencies(pk, options),
graphImpact: (nodeId: string) => workspaceQueryKeys.graphImpact(pk, nodeId),
// CLI History
cliHistoryList: workspaceQueryKeys.cliHistoryList(pk),
cliExecutionDetail: (executionId: string) =>
workspaceQueryKeys.cliExecutionDetail(pk, executionId),
};
}, [projectPath]);
return keys;
}
/**
* Type for the return value of useWorkspaceQueryKeys
*/
export type WorkspaceQueryKeys = ReturnType<typeof useWorkspaceQueryKeys>;

View File

@@ -3,9 +3,11 @@
// ========================================
// Typed fetch functions for API communication with CSRF token handling
import type { SessionMetadata, TaskData } from '../types/store';
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion } from '../types/store';
// Re-export types for backward compatibility
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion };
// ========== Types ==========
/**
* Raw backend session data structure matching the backend API response.
@@ -430,6 +432,33 @@ export async function removeRecentPath(path: string): Promise<string[]> {
return data.paths;
}
/**
* Switch workspace response
*/
export interface SwitchWorkspaceResponse {
projectPath: string;
recentPaths: string[];
activeSessions: SessionMetadata[];
archivedSessions: SessionMetadata[];
statistics: DashboardStats;
}
/**
* Remove recent path response
*/
export interface RemoveRecentPathResponse {
paths: string[];
}
/**
* Fetch data for path response
*/
export interface FetchDataForPathResponse {
projectOverview?: ProjectOverview | null;
sessions?: SessionsResponse;
statistics?: DashboardStats;
}
/**
* Switch to a different project path and load its data
*/
@@ -443,6 +472,20 @@ export async function loadDashboardData(path: string): Promise<{
return fetchApi(`/api/data?path=${encodeURIComponent(path)}`);
}
/**
* Switch workspace to a different project path
*/
export async function switchWorkspace(path: string): Promise<SwitchWorkspaceResponse> {
return fetchApi<SwitchWorkspaceResponse>(`/api/switch-path?path=${encodeURIComponent(path)}`);
}
/**
* Fetch data for a specific path
*/
export async function fetchDataForPath(path: string): Promise<FetchDataForPathResponse> {
return fetchApi<FetchDataForPathResponse>(`/api/data?path=${encodeURIComponent(path)}`);
}
// ========== Loops API ==========
export interface Loop {
@@ -1001,6 +1044,7 @@ export interface ConversationTurn {
};
timestamp: string;
duration_ms: number;
status?: 'success' | 'error' | 'timeout';
}
// ========== CLI Tools Config API ==========
@@ -1259,6 +1303,39 @@ export async function toggleMcpServer(
});
}
// ========== Codex MCP API ==========
/**
* Codex MCP Server - Read-only server with config path
* Extends McpServer with optional configPath field
*/
export interface CodexMcpServer extends McpServer {
configPath?: string;
}
export interface CodexMcpServersResponse {
servers: CodexMcpServer[];
configPath: string;
}
/**
* Fetch Codex MCP servers from config.toml
* Codex MCP servers are read-only (managed via config file)
*/
export async function fetchCodexMcpServers(): Promise<CodexMcpServersResponse> {
return fetchApi<CodexMcpServersResponse>('/api/mcp/codex-servers');
}
/**
* Add a new MCP server to Codex config
* Note: This requires write access to Codex config.toml
*/
export async function addCodexMcpServer(server: Omit<McpServer, 'name'>): Promise<CodexMcpServer> {
return fetchApi<CodexMcpServer>('/api/mcp/codex-add', {
method: 'POST',
body: JSON.stringify(server),
});
}
// ========== CLI Endpoints API ==========
export interface CliEndpoint {
@@ -1398,7 +1475,9 @@ export interface Hook {
description?: string;
enabled: boolean;
script?: string;
trigger: 'pre-commit' | 'post-commit' | 'pre-push' | 'custom';
command?: string;
trigger: string;
matcher?: string;
}
export interface HooksResponse {
@@ -1441,22 +1520,52 @@ export async function toggleHook(
});
}
/**
* Create a new hook
*/
export async function createHook(
input: { name: string; description?: string; trigger: string; matcher?: string; command: string }
): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/create', {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Update hook using dedicated update endpoint with partial input
*/
export async function updateHookConfig(
hookName: string,
input: { description?: string; trigger?: string; matcher?: string; command?: string }
): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/update', {
method: 'POST',
body: JSON.stringify({ name: hookName, ...input }),
});
}
/**
* Delete a hook
*/
export async function deleteHook(hookName: string): Promise<void> {
return fetchApi<void>(`/api/hooks/delete/${encodeURIComponent(hookName)}`, {
method: 'DELETE',
});
}
/**
* Install a hook from predefined template
*/
export async function installHookTemplate(templateId: string): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/install-template', {
method: 'POST',
body: JSON.stringify({ templateId }),
});
}
// ========== Rules API ==========
export interface Rule {
id: string;
name: string;
description?: string;
enabled: boolean;
category?: string;
pattern?: string;
severity?: 'error' | 'warning' | 'info';
}
export interface RulesResponse {
rules: Rule[];
}
/**
* Fetch all rules
*/
@@ -1492,3 +1601,328 @@ export async function toggleRule(
body: JSON.stringify({ enabled }),
});
}
/**
* Create a new rule
*/
export async function createRule(input: RuleCreateInput): Promise<Rule> {
return fetchApi<Rule>('/api/rules/create', {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Delete a rule
*/
export async function deleteRule(
ruleId: string,
location?: string
): Promise<void> {
return fetchApi<void>(`/api/rules/${encodeURIComponent(ruleId)}`, {
method: 'DELETE',
body: JSON.stringify({ location }),
});
}
// ========== CCW Tools MCP API ==========
/**
* CCW MCP configuration interface
*/
export interface CcwMcpConfig {
isInstalled: boolean;
enabledTools: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}
/**
* Fetch CCW Tools MCP configuration
*/
export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
const data = await fetchApi<CcwMcpConfig>('/api/mcp/ccw-config');
return data;
}
/**
* Update CCW Tools MCP configuration
*/
export async function updateCcwConfig(config: {
enabledTools?: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}): Promise<CcwMcpConfig> {
return fetchApi<CcwMcpConfig>('/api/mcp/ccw-config', {
method: 'PATCH',
body: JSON.stringify(config),
});
}
/**
* Install CCW Tools MCP server
*/
export async function installCcwMcp(): Promise<CcwMcpConfig> {
return fetchApi<CcwMcpConfig>('/api/mcp/ccw-install', {
method: 'POST',
});
}
/**
* Uninstall CCW Tools MCP server
*/
export async function uninstallCcwMcp(): Promise<void> {
await fetchApi<void>('/api/mcp/ccw-uninstall', {
method: 'POST',
});
}
// ========== Index Management API ==========
/**
* Fetch current index status
*/
export async function fetchIndexStatus(): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/status');
}
/**
* Rebuild index
*/
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/rebuild', {
method: 'POST',
body: JSON.stringify(request),
});
}
// ========== Prompt History API ==========
/**
* Prompt history response from backend
*/
export interface PromptsResponse {
prompts: Prompt[];
total: number;
}
/**
* Prompt insights response from backend
*/
export interface PromptInsightsResponse {
insights: PromptInsight[];
patterns: Pattern[];
suggestions: Suggestion[];
}
/**
* Analyze prompts request
*/
export interface AnalyzePromptsRequest {
tool?: 'gemini' | 'qwen' | 'codex';
promptIds?: string[];
limit?: number;
}
/**
* Fetch all prompts from history
*/
export async function fetchPrompts(): Promise<PromptsResponse> {
return fetchApi<PromptsResponse>('/api/memory/prompts');
}
/**
* Fetch prompt insights from backend
*/
export async function fetchPromptInsights(): Promise<PromptInsightsResponse> {
return fetchApi<PromptInsightsResponse>('/api/memory/insights');
}
/**
* Analyze prompts using AI tool
*/
export async function analyzePrompts(request: AnalyzePromptsRequest = {}): Promise<PromptInsightsResponse> {
return fetchApi<PromptInsightsResponse>('/api/memory/analyze', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Delete a prompt from history
*/
export async function deletePrompt(promptId: string): Promise<void> {
await fetchApi<void>('/api/memory/prompts/' + encodeURIComponent(promptId), {
method: 'DELETE',
});
}
// ========== File Explorer API ==========
/**
* File tree response from backend
*/
export interface FileTreeResponse {
rootNodes: import('../types/file-explorer').FileSystemNode[];
fileCount: number;
directoryCount: number;
totalSize: number;
buildTime: number;
}
/**
* Fetch file tree for a given root path
*/
export async function fetchFileTree(rootPath: string = '/', options: {
maxDepth?: number;
includeHidden?: boolean;
excludePatterns?: string[];
} = {}): Promise<FileTreeResponse> {
const params = new URLSearchParams();
params.append('rootPath', rootPath);
if (options.maxDepth !== undefined) params.append('maxDepth', String(options.maxDepth));
if (options.includeHidden !== undefined) params.append('includeHidden', String(options.includeHidden));
if (options.excludePatterns) params.append('excludePatterns', options.excludePatterns.join(','));
return fetchApi<FileTreeResponse>(`/api/explorer/tree?${params.toString()}`);
}
/**
* Fetch file content
*/
export async function fetchFileContent(filePath: string, options: {
encoding?: 'utf8' | 'ascii' | 'base64';
maxSize?: number;
} = {}): Promise<import('../types/file-explorer').FileContent> {
const params = new URLSearchParams();
params.append('path', filePath);
if (options.encoding) params.append('encoding', options.encoding);
if (options.maxSize !== undefined) params.append('maxSize', String(options.maxSize));
return fetchApi<import('../types/file-explorer').FileContent>(`/api/explorer/file?${params.toString()}`);
}
/**
* Search files request
*/
export interface SearchFilesRequest {
rootPath?: string;
query: string;
filePatterns?: string[];
excludePatterns?: string[];
maxResults?: number;
caseSensitive?: boolean;
}
/**
* Search files response
*/
export interface SearchFilesResponse {
results: Array<{
path: string;
name: string;
type: 'file' | 'directory';
matches: Array<{
line: number;
column: number;
context: string;
}>;
}>;
totalMatches: number;
searchTime: number;
}
/**
* Search files by content or name
*/
export async function searchFiles(request: SearchFilesRequest): Promise<SearchFilesResponse> {
return fetchApi<SearchFilesResponse>('/api/explorer/search', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Get available root directories
*/
export interface RootDirectory {
path: string;
name: string;
isWorkspace: boolean;
isGitRoot: boolean;
}
export async function fetchRootDirectories(): Promise<RootDirectory[]> {
return fetchApi<RootDirectory[]>('/api/explorer/roots');
}
// ========== Graph Explorer API ==========
/**
* Graph dependencies request
*/
export interface GraphDependenciesRequest {
rootPath?: string;
maxDepth?: number;
includeTypes?: string[];
excludePatterns?: string[];
}
/**
* Graph dependencies response
*/
export interface GraphDependenciesResponse {
nodes: import('../types/graph-explorer').GraphNode[];
edges: import('../types/graph-explorer').GraphEdge[];
metadata: import('../types/graph-explorer').GraphMetadata;
}
/**
* Fetch graph dependencies for code visualization
*/
export async function fetchGraphDependencies(request: GraphDependenciesRequest = {}): Promise<GraphDependenciesResponse> {
const params = new URLSearchParams();
if (request.rootPath) params.append('rootPath', request.rootPath);
if (request.maxDepth !== undefined) params.append('maxDepth', String(request.maxDepth));
if (request.includeTypes) params.append('includeTypes', request.includeTypes.join(','));
if (request.excludePatterns) params.append('excludePatterns', request.excludePatterns.join(','));
return fetchApi<GraphDependenciesResponse>(`/api/graph/dependencies?${params.toString()}`);
}
/**
* Graph impact analysis request
*/
export interface GraphImpactRequest {
nodeId: string;
direction?: 'upstream' | 'downstream' | 'both';
maxDepth?: number;
}
/**
* Graph impact analysis response
*/
export interface GraphImpactResponse {
nodeId: string;
dependencies: import('../types/graph-explorer').GraphNode[];
dependents: import('../types/graph-explorer').GraphNode[];
paths: Array<{
nodes: string[];
edges: string[];
}>;
}
/**
* Fetch impact analysis for a specific node
*/
export async function fetchGraphImpact(request: GraphImpactRequest): Promise<GraphImpactResponse> {
const params = new URLSearchParams();
params.append('nodeId', request.nodeId);
if (request.direction) params.append('direction', request.direction);
if (request.maxDepth !== undefined) params.append('maxDepth', String(request.maxDepth));
return fetchApi<GraphImpactResponse>(`/api/graph/impact?${params.toString()}`);
}

View File

@@ -0,0 +1,95 @@
// ========================================
// Workspace-Aware Query Keys Factory
// ========================================
// TanStack Query key factory with projectPath prefix for cache isolation
/**
* Workspace-aware query keys factory
* All keys include projectPath for cache isolation between workspaces
*/
export const workspaceQueryKeys = {
// Base key that includes projectPath
all: (projectPath: string) => ['workspace', projectPath] as const,
// ========== Sessions ==========
sessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'sessions'] as const,
sessionsList: (projectPath: string) => [...workspaceQueryKeys.sessions(projectPath), 'list'] as const,
sessionDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.sessions(projectPath), 'detail', sessionId] as const,
// ========== Tasks ==========
tasks: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'tasks'] as const,
tasksList: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.tasks(projectPath), 'list', sessionId] as const,
taskDetail: (projectPath: string, taskId: string) =>
[...workspaceQueryKeys.tasks(projectPath), 'detail', taskId] as const,
// ========== Loops ==========
loops: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'loops'] as const,
loopsList: (projectPath: string) => [...workspaceQueryKeys.loops(projectPath), 'list'] as const,
loopDetail: (projectPath: string, loopId: string) =>
[...workspaceQueryKeys.loops(projectPath), 'detail', loopId] as const,
// ========== Issues ==========
issues: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'issues'] as const,
issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const,
issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const,
issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const,
// ========== Memory ==========
memory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'memory'] as const,
memoryList: (projectPath: string) => [...workspaceQueryKeys.memory(projectPath), 'list'] as const,
memoryDetail: (projectPath: string, memoryId: string) =>
[...workspaceQueryKeys.memory(projectPath), 'detail', memoryId] as const,
// ========== Project Overview ==========
projectOverview: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'projectOverview'] as const,
projectOverviewDetail: (projectPath: string) =>
[...workspaceQueryKeys.projectOverview(projectPath), 'detail'] as const,
// ========== Lite Tasks ==========
liteTasks: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'liteTasks'] as const,
liteTasksList: (projectPath: string, type?: 'lite-plan' | 'lite-fix' | 'multi-cli-plan') =>
[...workspaceQueryKeys.liteTasks(projectPath), 'list', type] as const,
liteTaskDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.liteTasks(projectPath), 'detail', sessionId] as const,
// ========== Review Sessions ==========
reviewSessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'reviewSessions'] as const,
reviewSessionsList: (projectPath: string) => [...workspaceQueryKeys.reviewSessions(projectPath), 'list'] as const,
reviewSessionDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.reviewSessions(projectPath), 'detail', sessionId] as const,
// ========== Rules ==========
rules: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'rules'] as const,
rulesList: (projectPath: string) => [...workspaceQueryKeys.rules(projectPath), 'list'] as const,
// ========== Prompts ==========
prompts: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'prompts'] as const,
promptsList: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'list'] as const,
promptsInsights: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insights'] as const,
// ========== Index ==========
index: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'index'] as const,
indexStatus: (projectPath: string) => [...workspaceQueryKeys.index(projectPath), 'status'] as const,
// ========== File Explorer ==========
explorer: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'explorer'] as const,
explorerTree: (projectPath: string, rootPath?: string) =>
[...workspaceQueryKeys.explorer(projectPath), 'tree', rootPath] as const,
explorerFile: (projectPath: string, filePath?: string) =>
[...workspaceQueryKeys.explorer(projectPath), 'file', filePath] as const,
// ========== Graph Explorer ==========
graph: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'graph'] as const,
graphDependencies: (projectPath: string, options?: { maxDepth?: number }) =>
[...workspaceQueryKeys.graph(projectPath), 'dependencies', options] as const,
graphImpact: (projectPath: string, nodeId: string) =>
[...workspaceQueryKeys.graph(projectPath), 'impact', nodeId] as const,
// ========== CLI History ==========
cliHistory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'cliHistory'] as const,
cliHistoryList: (projectPath: string) => [...workspaceQueryKeys.cliHistory(projectPath), 'list'] as const,
cliExecutionDetail: (projectPath: string, executionId: string) =>
[...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const,
};

View File

@@ -0,0 +1,160 @@
{
"title": "Hook Manager",
"description": "Manage CLI hooks for automated workflows",
"allTools": "All tools",
"trigger": {
"UserPromptSubmit": "User Prompt Submit",
"PreToolUse": "Pre Tool Use",
"PostToolUse": "Post Tool Use",
"Stop": "Stop"
},
"form": {
"name": "Hook Name",
"namePlaceholder": "my-hook",
"description": "Description",
"descriptionPlaceholder": "What does this hook do?",
"trigger": "Trigger Event",
"matcher": "Tool Matcher",
"matcherPlaceholder": "e.g., Write|Edit (optional)",
"matcherHelp": "Regex pattern to match tool names. Leave empty to match all tools.",
"command": "Command",
"commandPlaceholder": "echo 'Hello World'",
"commandHelp": "Shell command to execute. Use environment variables like $CLAUDE_TOOL_NAME."
},
"validation": {
"nameRequired": "Hook name is required",
"nameInvalid": "Hook name can only contain letters, numbers, hyphens, and underscores",
"triggerRequired": "Trigger event is required",
"commandRequired": "Command is required"
},
"actions": {
"add": "Add Hook",
"addFirst": "Create Your First Hook",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete hook \"{hookName}\"?",
"enable": "Enable",
"disable": "Disable",
"expand": "Expand details",
"collapse": "Collapse details",
"expandAll": "Expand All",
"collapseAll": "Collapse All"
},
"dialog": {
"createTitle": "Create Hook",
"editTitle": "Edit Hook \"{hookName}\""
},
"stats": {
"total": "{count} total",
"enabled": "{count} enabled",
"count": "{enabled}/{total} hooks"
},
"filters": {
"searchPlaceholder": "Search hooks by name, description, or trigger..."
},
"empty": {
"title": "No hooks found",
"description": "Create your first hook to automate your CLI workflow",
"noHooksInEvent": "No hooks configured for this event"
},
"templates": {
"title": "Quick Install Templates",
"description": "One-click installation for common hook patterns",
"categories": {
"notification": "Notification",
"indexing": "Indexing",
"automation": "Automation"
},
"templates": {
"ccw-notify": {
"name": "CCW Dashboard Notify",
"description": "Send notifications to CCW dashboard when files are written"
},
"codexlens-update": {
"name": "CodexLens Auto-Update",
"description": "Update CodexLens index when files are written or edited"
},
"git-add": {
"name": "Auto Git Stage",
"description": "Automatically stage written files to git"
},
"lint-check": {
"name": "Auto ESLint",
"description": "Run ESLint on JavaScript/TypeScript files after write"
},
"log-tool": {
"name": "Tool Usage Logger",
"description": "Log all tool executions to a file for audit trail"
}
},
"actions": {
"install": "Install",
"installed": "Installed"
}
},
"wizards": {
"title": "Hook Wizard",
"launch": "Wizard",
"sectionTitle": "Hook Wizards",
"sectionDescription": "Create hooks with guided step-by-step wizards",
"platform": {
"detected": "Detected Platform",
"compatible": "Compatible",
"incompatible": "Incompatible",
"compatibilityError": "This hook is not compatible with your platform",
"compatibilityWarning": "Some features may not work on your platform"
},
"steps": {
"triggerEvent": "This hook will trigger on",
"review": {
"title": "Review Configuration",
"description": "Review your hook configuration before creating",
"hookType": "Hook Type",
"trigger": "Trigger Event",
"platform": "Platform",
"commandPreview": "Command Preview"
}
},
"navigation": {
"previous": "Previous",
"next": "Next",
"create": "Create Hook",
"creating": "Creating..."
},
"memoryUpdate": {
"title": "Memory Update Wizard",
"description": "Configure hook to update CLAUDE.md on session end",
"shortDescription": "Update CLAUDE.md automatically",
"claudePath": "CLAUDE.md Path",
"updateFrequency": "Update Frequency",
"frequency": {
"sessionEnd": "Session End",
"hourly": "Hourly",
"daily": "Daily"
}
},
"dangerProtection": {
"title": "Danger Protection Wizard",
"description": "Configure confirmation hook for dangerous operations",
"shortDescription": "Confirm dangerous operations",
"keywords": "Dangerous Keywords",
"keywordsHelp": "Enter one keyword per line",
"confirmationMessage": "Confirmation Message",
"allowBypass": "Allow bypass with --force flag"
},
"skillContext": {
"title": "SKILL Context Wizard",
"description": "Configure hook to load SKILL based on prompt keywords",
"shortDescription": "Auto-load SKILL based on keywords",
"loadingSkills": "Loading available skills...",
"keywordPlaceholder": "Enter keyword",
"selectSkill": "Select skill",
"addPair": "Add Keyword-Skill Pair",
"priority": "Priority",
"priorityHigh": "High",
"priorityMedium": "Medium",
"priorityLow": "Low",
"keywordMappings": "Keyword Mappings"
}
}
}

View File

@@ -129,5 +129,34 @@
"prompt": "Prompt",
"output": "Output",
"details": "Details"
},
"streamPanel": {
"turns": "turns",
"perTurnView": "Per-Turn View",
"concatenatedView": "Concatenated View",
"userPrompt": "User Prompt",
"assistantResponse": "Assistant Response",
"errors": "Errors",
"truncatedNotice": "Output was truncated due to size limits.",
"latest": "Latest",
"copyId": "Copy ID",
"copyPrompt": "Copy Prompt",
"concatenatedPrompt": "Concatenated Prompt",
"newRequest": "NEW REQUEST",
"noOutput": "[No output]",
"yourNextPrompt": "[Your next prompt here]",
"conversationHistory": "CONVERSATION HISTORY",
"loading": "Loading...",
"noDetails": "No execution details available"
},
"details": {
"turn": "Turn",
"tool": "Tool",
"mode": "Mode",
"duration": "Duration",
"created": "Created",
"id": "ID",
"timestamp": "Timestamp",
"status": "Status"
}
}

View File

@@ -0,0 +1,18 @@
{
"title": "CLI Stream Monitor",
"searchPlaceholder": "Search output...",
"noExecutions": "No active CLI executions",
"noExecutionsHint": "Start a CLI command to see streaming output",
"selectExecution": "Select an execution to view output",
"status": {
"running": "Running",
"completed": "Completed",
"error": "Error"
},
"recovered": "Recovered",
"lines": "lines",
"autoScroll": "Auto-scroll",
"scrollToBottom": "Scroll to bottom",
"close": "Close",
"refresh": "Refresh"
}

View File

@@ -35,6 +35,7 @@
"submit": "Submit",
"reset": "Reset",
"resetDesc": "Reset all user preferences to their default values. This cannot be undone.",
"saving": "Saving...",
"resetConfirm": "Reset all settings to defaults?",
"resetToDefaults": "Reset to Defaults",
"enable": "Enable",
@@ -68,7 +69,9 @@
"creating": "Creating...",
"deleting": "Deleting...",
"label": "Status",
"openIssues": "Open Issues"
"openIssues": "Open Issues",
"enabled": "Enabled",
"disabled": "Disabled"
},
"priority": {
"low": "Low",

View File

@@ -0,0 +1,51 @@
{
"page": {
"title": "Execution Monitor",
"subtitle": "View real-time execution status and history"
},
"currentExecution": {
"title": "Current Execution",
"noExecution": "No workflow is currently executing",
"expand": "Expand",
"collapse": "Collapse"
},
"stats": {
"title": "Statistics",
"totalExecutions": "Total Executions",
"successRate": "Success Rate",
"avgDuration": "Avg Duration",
"nodeSuccessRate": "Node Success Rate"
},
"history": {
"title": "Execution History",
"empty": "No execution history",
"tabs": {
"byWorkflow": "By Workflow",
"timeline": "Timeline",
"list": "List View"
}
},
"filters": {
"workflow": "Workflow",
"status": "Status",
"dateRange": "Date Range",
"all": "All Workflows",
"allStatus": "All Status"
},
"execution": {
"status": {
"pending": "Pending",
"running": "Running",
"paused": "Paused",
"completed": "Completed",
"failed": "Failed"
},
"duration": "Duration",
"startedAt": "Started",
"completedAt": "Completed",
"nodes": "Nodes",
"progress": "Progress",
"logs": "Logs",
"viewDetails": "View Details"
}
}

View File

@@ -0,0 +1,57 @@
{
"title": "File Explorer",
"description": "Browse and search files in your project",
"viewMode": {
"tree": "Tree",
"list": "List",
"compact": "Compact"
},
"sortOrder": {
"name": "Name",
"size": "Size",
"modified": "Modified",
"type": "Type"
},
"tree": {
"loading": "Loading file tree...",
"stats": "{files} items",
"empty": "No files found",
"error": "Failed to load file tree"
},
"preview": {
"loading": "Loading file content...",
"errorTitle": "Error loading file",
"emptyTitle": "No file selected",
"emptyMessage": "Select a file from the tree view to preview its content",
"binaryTitle": "Binary file",
"binaryMessage": "This file type cannot be previewed",
"tooLargeTitle": "File too large",
"tooLargeMessage": "File exceeds preview limit of {size}",
"copy": "Copy to clipboard",
"lastModified": "Last modified: {time}"
},
"toolbar": {
"searchPlaceholder": "Search files...",
"selectRoot": "Select directory",
"rootDirectory": "Root Directory",
"noRoots": "No directories available",
"viewMode": "View Mode",
"sortBy": "Sort By",
"moreOptions": "More Options",
"options": "Options",
"showHidden": "Show hidden files",
"expandAll": "Expand all",
"collapseAll": "Collapse all"
},
"errors": {
"loadFailed": "Failed to load file tree",
"loadFileFailed": "Failed to load file content",
"searchFailed": "Search failed",
"networkError": "Network error occurred"
},
"context": {
"hasClaudeMd": "Contains CLAUDE.md context",
"gitRoot": "Git repository root",
"workspace": "Workspace directory"
}
}

View File

@@ -40,5 +40,8 @@
"empty": {
"title": "No Tasks Found",
"message": "No fix tasks match the current filter."
},
"phase": {
"execution": "Execution"
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "Graph Explorer",
"description": "Visualize code dependencies and relationships",
"filters": {
"title": "Filters",
"nodeTypes": "Node Types",
"edgeTypes": "Edge Types",
"selectNodeTypes": "Select Node Types",
"selectEdgeTypes": "Select Edge Types",
"searchPlaceholder": "Search nodes...",
"showOnlyIssues": "Show Only Issues",
"showIsolatedNodes": "Show Isolated Nodes",
"minComplexity": "Min Complexity",
"maxDepth": "Max Depth",
"reset": "Reset"
},
"nodeTypes": {
"component": "Component",
"module": "Module",
"function": "Function",
"class": "Class",
"interface": "Interface",
"variable": "Variable",
"file": "File",
"folder": "Folder",
"dependency": "Dependency",
"api": "API",
"database": "Database",
"service": "Service",
"hook": "Hook",
"utility": "Utility",
"unknown": "Unknown"
},
"edgeTypes": {
"imports": "Imports",
"exports": "Exports",
"extends": "Extends",
"implements": "Implements",
"uses": "Uses",
"dependsOn": "Depends On",
"calls": "Calls",
"instantiates": "Instantiates",
"contains": "Contains",
"relatedTo": "Related To",
"dataFlow": "Data Flow",
"event": "Event",
"unknown": "Unknown"
},
"actions": {
"fitView": "Fit to Screen",
"refresh": "Refresh",
"resetFilters": "Reset Filters",
"export": "Export",
"settings": "Settings",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out"
},
"legend": {
"title": "Legend",
"nodeTypes": "Node Types",
"edgeTypes": "Edge Types",
"component": "Component",
"module": "Module",
"class": "Class",
"function": "Function",
"variable": "Variable",
"imports": "Imports (solid line)",
"calls": "Calls (green line)",
"extends": "Extends (dashed line)"
},
"sidebar": {
"title": "Graph Explorer",
"nodeDetails": "Node Details",
"hasIssues": "Has Issues",
"filePath": "File Path",
"lineNumber": "Line Number",
"category": "Category",
"lineCount": "Lines of Code",
"documentation": "Documentation",
"tags": "Tags",
"issues": "Issues",
"instructions": "Click on a node to view details. Use the toolbar to filter nodes and edges."
},
"status": {
"nodes": "Nodes",
"edges": "Edges",
"loading": "Loading graph...",
"updating": "Updating...",
"filtered": "Showing {count} of {total} nodes"
},
"error": {
"loading": "Failed to load graph: {message}",
"empty": "No graph data available",
"unknown": "An unknown error occurred"
},
"empty": "No graph data available. Try adjusting filters or refresh the page."
}

View File

@@ -30,6 +30,26 @@
"message": "Create an issue to track bugs or feature requests."
}
},
"help": {
"gettingStarted": {
"title": "Getting Started",
"description": "Learn the basics of CCW Dashboard and workflow management",
"heading": "Getting Started with CCW"
},
"orchestratorGuide": {
"title": "Orchestrator Guide",
"description": "Master the visual workflow editor with drag-drop flows"
},
"sessionsManagement": {
"title": "Sessions Management",
"description": "Understanding workflow sessions and task tracking"
},
"cliIntegration": {
"title": "CLI Integration",
"description": "Using CCW commands and CLI tool integration",
"heading": "CLI Integration"
}
},
"errors": {
"loadFailed": "Failed to load dashboard data",
"retry": "Retry"

View File

@@ -0,0 +1,45 @@
{
"title": "Code Index",
"description": "Manage the code search index for faster navigation and code discovery",
"sections": {
"status": "Status",
"actions": "Actions",
"settings": "Settings"
},
"status": {
"idle": "Idle",
"building": "Building",
"completed": "Ready",
"failed": "Failed"
},
"actions": {
"rebuild": "Rebuild Index",
"rebuildFull": "Full Rebuild",
"cancel": "Cancel"
},
"stats": {
"totalFiles": "Total Files",
"totalFilesDesc": "Files in the index",
"lastUpdated": "Last Updated",
"lastUpdatedDesc": "When the index was last built",
"buildTime": "Build Time",
"buildTimeDesc": "Time taken for last build",
"never": "Never indexed"
},
"time": {
"justNow": "Just now",
"minutesAgo": "{value}m ago",
"hoursAgo": "{value}h ago",
"daysAgo": "{value}d ago"
},
"errors": {
"rebuildFailed": "Index rebuild failed",
"loadFailed": "Failed to load index status"
},
"settings": {
"autoRebuild": "Auto Rebuild",
"autoRebuildDesc": "Automatically rebuild index when code changes",
"rebuildInterval": "Rebuild Interval",
"rebuildIntervalDesc": "How often to check for code changes"
}
}

View File

@@ -21,7 +21,19 @@ import reviewSession from './review-session.json';
import sessionDetail from './session-detail.json';
import skills from './skills.json';
import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json';
import theme from './theme.json';
import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json';
import index from './index.json';
import rules from './rules.json';
import prompts from './prompts.json';
import explorer from './explorer.json';
import graph from './graph.json';
import notification from './notification.json';
import notifications from './notifications.json';
import workspace from './workspace.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -66,5 +78,17 @@ export default {
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'),
...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'),
...flattenMessages(index, 'index'),
...flattenMessages(rules, 'rules'),
...flattenMessages(prompts, 'prompts'),
...flattenMessages(explorer, 'explorer'),
...flattenMessages(graph, 'graph'),
...flattenMessages(notification, 'notificationPanel'),
...flattenMessages(notifications, 'notifications'),
...flattenMessages(workspace, 'workspace'),
} as Record<string, string>;

View File

@@ -19,6 +19,8 @@
"title": "No tasks in this session",
"message": "This session does not contain any tasks yet."
},
"untitled": "Untitled Task",
"discussionTopic": "Discussion Topic",
"notFound": {
"title": "Lite Task Not Found",
"message": "The requested lite task session could not be found."

View File

@@ -1,6 +1,10 @@
{
"title": "MCP Servers",
"description": "Manage Model Context Protocol (MCP) servers for cross-CLI integration",
"mode": {
"claude": "Claude",
"codex": "Codex"
},
"scope": {
"global": "Global",
"project": "Project"
@@ -18,6 +22,11 @@
"command": "Command",
"args": "Arguments",
"env": "Environment Variables",
"codex": {
"configPath": "Config Path",
"readOnly": "Read-only",
"readOnlyNotice": "Codex MCP servers are managed via config.toml and cannot be edited here."
},
"filters": {
"all": "All",
"searchPlaceholder": "Search servers by name or command..."
@@ -33,5 +42,89 @@
"emptyState": {
"title": "No MCP Servers Found",
"message": "Add an MCP server to enable cross-CLI integration with tools like Claude, Codex, and Qwen."
},
"dialog": {
"addTitle": "Add MCP Server",
"editTitle": "Edit MCP Server \"{name}\"",
"form": {
"template": "Template",
"templatePlaceholder": "Select a template to pre-fill the form",
"name": "Server Name",
"namePlaceholder": "e.g., my-mcp-server",
"command": "Command",
"commandPlaceholder": "e.g., npx, python, node",
"args": "Arguments",
"argsPlaceholder": "Comma-separated arguments, e.g., -v, --option=value",
"argsHint": "Separate multiple arguments with commas",
"env": "Environment Variables",
"envPlaceholder": "Key=value pairs (one per line), e.g.,\nAPI_KEY=your_key\nDEBUG=true",
"envHint": "Enter one key=value pair per line",
"scope": "Scope",
"enabled": "Enable this server"
},
"templates": {
"npx-stdio": "NPX STDIO",
"python-stdio": "Python STDIO",
"sse-server": "SSE Server"
},
"validation": {
"nameRequired": "Server name is required",
"nameExists": "A server with this name already exists",
"commandRequired": "Command is required"
},
"actions": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
}
},
"ccw": {
"title": "CCW MCP Server",
"description": "Special built-in MCP server for CCW file operations and memory management",
"status": {
"installed": "Installed",
"notInstalled": "Not Installed",
"special": "Built-in"
},
"tools": {
"label": "Available Tools",
"core": "Core",
"write_file": {
"name": "write_file",
"desc": "Write or create new files"
},
"edit_file": {
"name": "edit_file",
"desc": "Edit or replace file contents"
},
"read_file": {
"name": "read_file",
"desc": "Read file contents"
},
"core_memory": {
"name": "core_memory",
"desc": "Manage core memory entries"
}
},
"paths": {
"label": "Path Configuration",
"projectRoot": "Project Root",
"projectRootPlaceholder": "e.g., D:\\Projects\\MyProject",
"allowedDirs": "Allowed Directories",
"allowedDirsPlaceholder": "dir1,dir2,dir3",
"allowedDirsHint": "Comma-separated list of allowed directories",
"disableSandbox": "Disable Sandbox"
},
"actions": {
"enableAll": "Enable All",
"disableAll": "Disable All",
"install": "Install CCW MCP",
"installing": "Installing...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
"saveConfig": "Save Configuration",
"saving": "Saving..."
}
}
}

View File

@@ -11,11 +11,16 @@
"skills": "Skills",
"commands": "Commands",
"memory": "Memory",
"prompts": "Prompt History",
"settings": "Settings",
"mcp": "MCP Servers",
"endpoints": "CLI Endpoints",
"installations": "Installations",
"help": "Help"
"help": "Help",
"hooks": "Hooks",
"rules": "Rules",
"explorer": "File Explorer",
"graph": "Graph Explorer"
},
"sidebar": {
"collapse": "Collapse",

View File

@@ -0,0 +1,9 @@
{
"title": "Notifications",
"markAllRead": "Mark Read",
"clearAll": "Clear All",
"showMore": "Show more",
"showLess": "Show less",
"empty": "No notifications",
"emptyHint": "Notifications will appear here"
}

View File

@@ -0,0 +1,18 @@
{
"title": "Notifications",
"empty": "No notifications",
"emptyHint": "Notifications will appear here",
"markAllRead": "Mark Read",
"clearAll": "Clear All",
"showMore": "Show more",
"showLess": "Show less",
"systemNotifications": "System Notifications",
"systemNotificationsDesc": "Show browser native notifications for important events",
"justNow": "just now",
"minutesAgo": "{0}m ago",
"hoursAgo": "{0}h ago",
"daysAgo": "{0}d ago",
"oneMinuteAgo": "1m ago",
"oneHourAgo": "1h ago",
"oneDayAgo": "1d ago"
}

View File

@@ -59,5 +59,136 @@
"timeline": "Timeline",
"variables": "Variables",
"realtime": "Real-time Updates"
},
"notifications": {
"flowCreated": "Flow Created",
"flowSaved": "Flow Saved",
"saveFailed": "Save Failed",
"flowLoaded": "Flow Loaded",
"loadFailed": "Load Failed",
"flowDeleted": "Flow Deleted",
"deleteFailed": "Delete Failed",
"flowDuplicated": "Flow Duplicated",
"duplicateFailed": "Duplicate Failed"
},
"templateLibrary": {
"title": "Template Library",
"description": "Browse and import workflow templates, or export your current flow as a template.",
"searchPlaceholder": "Search templates...",
"allCategories": "All",
"exportCurrent": "Export Current",
"close": "Close",
"errors": {
"loadFailed": "Failed to load templates"
},
"emptyState": {
"title": "No templates found",
"searchSuggestion": "Try a different search query"
},
"footer": {
"templateCount": "{count} template",
"templateCount_plural": "{count} templates"
},
"card": {
"nodes": "nodes",
"import": "Import",
"delete": "Delete"
},
"exportDialog": {
"title": "Export as Template",
"description": "Save this flow as a reusable template in your library.",
"fields": {
"name": "Name",
"namePlaceholder": "Template name",
"description": "Description",
"descriptionPlaceholder": "Brief description of this template",
"category": "Category",
"categoryPlaceholder": "e.g., Development, Testing, Deployment",
"tags": "Tags (comma-separated)",
"tagsPlaceholder": "e.g., react, testing, ci/cd"
},
"actions": {
"cancel": "Cancel",
"export": "Export"
}
}
},
"toolbar": {
"placeholder": "Flow name",
"unsavedChanges": "Unsaved changes",
"new": "New",
"save": "Save",
"load": "Load",
"export": "Export",
"templates": "Templates",
"savedFlows": "Saved Flows ({count})",
"loading": "Loading...",
"noSavedFlows": "No saved flows",
"duplicate": "Duplicate",
"delete": "Delete"
},
"palette": {
"title": "Node Palette",
"open": "Open node palette",
"collapse": "Collapse palette",
"instructions": "Drag nodes onto the canvas to add them to your workflow",
"nodeTypes": "Node Types",
"tipLabel": "Tip:",
"tip": "Connect nodes by dragging from output to input handles"
},
"propertyPanel": {
"title": "Properties",
"open": "Open properties panel",
"close": "Close panel",
"selectNode": "Select a node to edit its properties",
"deleteNode": "Delete Node",
"placeholders": {
"nodeLabel": "Node label",
"commandName": "/command-name",
"commandArgs": "Command arguments",
"timeout": "60000",
"path": "/path/to/file",
"content": "File content...",
"destinationPath": "/path/to/destination",
"variableName": "variableName",
"condition": "e.g., result.success === true",
"trueLabel": "True",
"falseLabel": "False"
},
"labels": {
"label": "Label",
"command": "Command",
"arguments": "Arguments",
"executionMode": "Execution Mode",
"onError": "On Error",
"timeout": "Timeout (ms)",
"operation": "Operation",
"path": "Path",
"content": "Content",
"destinationPath": "Destination Path",
"outputVariable": "Output Variable",
"addToContext": "Add to context",
"condition": "Condition",
"trueLabel": "True Label",
"falseLabel": "False Label",
"joinMode": "Join Mode",
"failFast": "Fail fast (stop all branches on first error)"
},
"options": {
"modeAnalysis": "Analysis (Read-only)",
"modeWrite": "Write (Modify files)",
"errorStop": "Stop execution",
"errorContinue": "Continue",
"errorRetry": "Retry",
"operationRead": "Read",
"operationWrite": "Write",
"operationAppend": "Append",
"operationDelete": "Delete",
"operationCopy": "Copy",
"operationMove": "Move",
"joinModeAll": "Wait for all branches",
"joinModeAny": "Complete when any branch finishes",
"joinModeNone": "No synchronization"
}
}
}

View File

@@ -30,7 +30,14 @@
"devIndex": {
"title": "Development History",
"categories": "Categories",
"timeline": "Timeline"
"timeline": "Timeline",
"category": {
"features": "Features",
"enhancements": "Enhancements",
"bugfixes": "Bug Fixes",
"refactorings": "Refactorings",
"documentation": "Documentation"
}
},
"guidelines": {
"title": "Project Guidelines",

View File

@@ -0,0 +1,74 @@
{
"title": "Prompt History",
"description": "View and analyze your prompt history with AI insights",
"searchPlaceholder": "Search prompts...",
"filterByIntent": "Filter by intent",
"intents": {
"all": "All Intents",
"intent": "Intent",
"bug-fix": "Bug Fix",
"feature": "Feature",
"refactor": "Refactor",
"document": "Document",
"analyze": "Analyze"
},
"stats": {
"totalCount": "Total Prompts",
"totalCountDesc": "All stored prompts",
"avgLength": "Avg Length",
"avgLengthDesc": "Mean character count",
"topIntent": "Top Intent",
"topIntentDesc": "Most used category",
"noIntent": "N/A"
},
"card": {
"untitled": "Untitled Prompt",
"used": "Used {count} times"
},
"timeline": {
"ungrouped": "Ungrouped",
"session": "Session: {session}"
},
"actions": {
"copy": "Copy prompt",
"copied": "Copied!",
"delete": "Delete",
"expand": "Expand",
"collapse": "Collapse"
},
"insights": {
"title": "AI Insights",
"analyze": "Analyze",
"analyzing": "Analyzing prompts...",
"selectTool": "Select tool",
"confidence": "confidence",
"empty": {
"title": "No insights yet",
"message": "Run an analysis to get AI-powered insights about your prompt patterns and suggestions for improvement."
},
"sections": {
"insights": "Insights",
"patterns": "Detected Patterns",
"suggestions": "Suggestions"
}
},
"suggestions": {
"types": {
"refactor": "Refactor",
"optimize": "Optimize",
"fix": "Fix",
"document": "Document"
},
"effort": "Effort"
},
"dialog": {
"deleteTitle": "Delete Prompt",
"deleteConfirm": "Are you sure you want to delete this prompt? This action cannot be undone."
},
"emptyState": {
"title": "No prompts found",
"message": "No prompts match your current filter.",
"noPrompts": "No prompts yet",
"createFirst": "Create your first prompt to start building history"
}
}

View File

@@ -0,0 +1,79 @@
{
"title": "Rules Manager",
"description": "Manage Claude Code memory rules and configurations",
"severity": {
"error": "Error",
"warning": "Warning",
"info": "Info"
},
"location": {
"project": "Project",
"user": "User"
},
"actions": {
"edit": "Edit",
"delete": "Delete",
"create": "Create Rule",
"update": "Update Rule",
"toggle": "Toggle Enabled",
"enable": "Enable Rule",
"disable": "Disable Rule"
},
"filters": {
"all": "All",
"enabled": "Enabled",
"disabled": "Disabled",
"location": "Location",
"category": "Category"
},
"searchPlaceholder": "Search rules...",
"emptyState": {
"title": "No Rules Found",
"message": "No rules match your current filter.",
"createFirst": "Create your first rule to get started"
},
"card": {
"pattern": "Pattern",
"subdirectory": "Directory"
},
"dialog": {
"addTitle": "Create New Rule",
"editTitle": "Edit Rule: {name}",
"deleteTitle": "Delete Rule",
"deleteConfirm": "Are you sure you want to delete this rule? This action cannot be undone.",
"description": "Configure Claude Code memory rules to guide AI behavior",
"form": {
"name": "Rule Name",
"namePlaceholder": "e.g., Code Style Guide",
"description": "Description",
"descriptionPlaceholder": "Brief description of what this rule enforces",
"category": "Category",
"severity": "Severity",
"fileName": "File Name",
"fileNamePlaceholder": "rule-name.md",
"location": "Location",
"subdirectory": "Subdirectory (optional)",
"subdirectoryPlaceholder": "e.g., coding/security",
"pattern": "File Pattern (optional)",
"patternPlaceholder": "e.g., src/**/*.ts",
"content": "Rule Content",
"contentPlaceholder": "Enter the rule content in markdown format...",
"enabled": "Enabled"
},
"validation": {
"nameRequired": "Rule name is required",
"fileNameRequired": "File name is required",
"fileNameMd": "File name must end with .md",
"locationRequired": "Location is required",
"contentRequired": "Rule content is required"
},
"actions": {
"saving": "Saving..."
}
},
"status": {
"creating": "Creating...",
"updating": "Updating...",
"deleting": "Deleting..."
}
}

View File

@@ -0,0 +1,21 @@
{
"title": {
"colorScheme": "Color Scheme",
"themeMode": "Theme Mode"
},
"colorScheme": {
"blue": "Classic Blue",
"green": "Deep Green",
"orange": "Vibrant Orange",
"purple": "Elegant Purple"
},
"themeMode": {
"light": "Light",
"dark": "Dark"
},
"select": {
"colorScheme": "Select {name} theme",
"themeMode": "Select {name} mode"
},
"current": "Current theme: {name}"
}

View File

@@ -0,0 +1,29 @@
{
"selector": {
"noWorkspace": "No workspace selected",
"recentPaths": "Recent Projects",
"noRecentPaths": "No recent projects",
"current": "Current",
"browse": "Select Folder...",
"removePath": "Remove from recent",
"ariaLabel": "Workspace selector"
},
"dialog": {
"title": "Select Project Folder",
"placeholder": "Enter project path...",
"help": "The path to your project directory"
},
"actions": {
"switch": "Switch Workspace",
"switchFailed": "Failed to switch workspace",
"switchSuccess": "Successfully switched to {path}"
},
"validation": {
"pathRequired": "Project path is required",
"invalidPath": "Invalid project path: {path}"
},
"notifications": {
"switching": "Switching workspace...",
"refreshing": "Refreshing recent paths..."
}
}

View File

@@ -0,0 +1,160 @@
{
"title": "Hook Manager",
"description": "Manage CLI hooks for automated workflows",
"allTools": "All tools",
"trigger": {
"UserPromptSubmit": "User Prompt Submit",
"PreToolUse": "Pre Tool Use",
"PostToolUse": "Post Tool Use",
"Stop": "Stop"
},
"form": {
"name": "Hook Name",
"namePlaceholder": "my-hook",
"description": "Description",
"descriptionPlaceholder": "What does this hook do?",
"trigger": "Trigger Event",
"matcher": "Tool Matcher",
"matcherPlaceholder": "e.g., Write|Edit (optional)",
"matcherHelp": "Regex pattern to match tool names. Leave empty to match all tools.",
"command": "Command",
"commandPlaceholder": "echo 'Hello World'",
"commandHelp": "Shell command to execute. Use environment variables like $CLAUDE_TOOL_NAME."
},
"validation": {
"nameRequired": "Hook name is required",
"nameInvalid": "Hook name can only contain letters, numbers, hyphens, and underscores",
"triggerRequired": "Trigger event is required",
"commandRequired": "Command is required"
},
"actions": {
"add": "Add Hook",
"addFirst": "Create Your First Hook",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete hook \"{hookName}\"?",
"enable": "Enable",
"disable": "Disable",
"expand": "Expand details",
"collapse": "Collapse details",
"expandAll": "Expand All",
"collapseAll": "Collapse All"
},
"dialog": {
"createTitle": "Create Hook",
"editTitle": "Edit Hook \"{hookName}\""
},
"stats": {
"total": "{count} total",
"enabled": "{count} enabled",
"count": "{enabled}/{total} hooks"
},
"filters": {
"searchPlaceholder": "Search hooks by name, description, or trigger..."
},
"empty": {
"title": "No hooks found",
"description": "Create your first hook to automate your CLI workflow",
"noHooksInEvent": "No hooks configured for this event"
},
"templates": {
"title": "Quick Install Templates",
"description": "One-click installation for common hook patterns",
"categories": {
"notification": "Notification",
"indexing": "Indexing",
"automation": "Automation"
},
"templates": {
"ccw-notify": {
"name": "CCW Dashboard Notify",
"description": "Send notifications to CCW dashboard when files are written"
},
"codexlens-update": {
"name": "CodexLens Auto-Update",
"description": "Update CodexLens index when files are written or edited"
},
"git-add": {
"name": "Auto Git Stage",
"description": "Automatically stage written files to git"
},
"lint-check": {
"name": "Auto ESLint",
"description": "Run ESLint on JavaScript/TypeScript files after write"
},
"log-tool": {
"name": "Tool Usage Logger",
"description": "Log all tool executions to a file for audit trail"
}
},
"actions": {
"install": "Install",
"installed": "Installed"
}
},
"wizards": {
"title": "Hook Wizard",
"launch": "Wizard",
"sectionTitle": "Hook Wizards",
"sectionDescription": "Create hooks with guided step-by-step wizards",
"platform": {
"detected": "Detected Platform",
"compatible": "Compatible",
"incompatible": "Incompatible",
"compatibilityError": "This hook is not compatible with your platform",
"compatibilityWarning": "Some features may not work on your platform"
},
"steps": {
"triggerEvent": "This hook will trigger on",
"review": {
"title": "Review Configuration",
"description": "Review your hook configuration before creating",
"hookType": "Hook Type",
"trigger": "Trigger Event",
"platform": "Platform",
"commandPreview": "Command Preview"
}
},
"navigation": {
"previous": "Previous",
"next": "Next",
"create": "Create Hook",
"creating": "Creating..."
},
"memoryUpdate": {
"title": "Memory Update Wizard",
"description": "Configure hook to update CLAUDE.md on session end",
"shortDescription": "Update CLAUDE.md automatically",
"claudePath": "CLAUDE.md Path",
"updateFrequency": "Update Frequency",
"frequency": {
"sessionEnd": "Session End",
"hourly": "Hourly",
"daily": "Daily"
}
},
"dangerProtection": {
"title": "Danger Protection Wizard",
"description": "Configure confirmation hook for dangerous operations",
"shortDescription": "Confirm dangerous operations",
"keywords": "Dangerous Keywords",
"keywordsHelp": "Enter one keyword per line",
"confirmationMessage": "Confirmation Message",
"allowBypass": "Allow bypass with --force flag"
},
"skillContext": {
"title": "SKILL Context Wizard",
"description": "Configure hook to load SKILL based on prompt keywords",
"shortDescription": "Auto-load SKILL based on keywords",
"loadingSkills": "Loading available skills...",
"keywordPlaceholder": "Enter keyword",
"selectSkill": "Select skill",
"addPair": "Add Keyword-Skill Pair",
"priority": "Priority",
"priorityHigh": "High",
"priorityMedium": "Medium",
"priorityLow": "Low",
"keywordMappings": "Keyword Mappings"
}
}
}

View File

@@ -129,5 +129,34 @@
"prompt": "提示词",
"output": "输出",
"details": "详情"
},
"streamPanel": {
"turns": "回合",
"perTurnView": "分回合视图",
"concatenatedView": "连接视图",
"userPrompt": "用户提示词",
"assistantResponse": "助手响应",
"errors": "错误",
"truncatedNotice": "输出因大小限制被截断。",
"latest": "最新",
"copyId": "复制 ID",
"copyPrompt": "复制提示词",
"concatenatedPrompt": "连接提示词",
"newRequest": "新请求",
"noOutput": "[无输出]",
"yourNextPrompt": "[您的下一条提示词]",
"conversationHistory": "对话历史",
"loading": "加载中...",
"noDetails": "无执行详情"
},
"details": {
"turn": "回合",
"tool": "工具",
"mode": "模式",
"duration": "时长",
"created": "创建时间",
"id": "ID",
"timestamp": "时间戳",
"status": "状态"
}
}

View File

@@ -0,0 +1,18 @@
{
"title": "CLI 流式监控",
"searchPlaceholder": "搜索输出...",
"noExecutions": "没有正在执行的 CLI 任务",
"noExecutionsHint": "启动 CLI 命令以查看实时输出",
"selectExecution": "选择一个任务以查看输出",
"status": {
"running": "运行中",
"completed": "已完成",
"error": "错误"
},
"recovered": "已恢复",
"lines": "行",
"autoScroll": "自动滚动",
"scrollToBottom": "滚动到底部",
"close": "关闭",
"refresh": "刷新"
}

View File

@@ -35,6 +35,7 @@
"submit": "提交",
"reset": "重置",
"resetDesc": "将所有用户偏好重置为默认值。此操作无法撤销。",
"saving": "Saving...",
"resetConfirm": "确定要将所有设置重置为默认值吗?",
"resetToDefaults": "重置为默认值",
"enable": "启用",
@@ -68,7 +69,9 @@
"creating": "创建中...",
"deleting": "删除中...",
"label": "状态",
"openIssues": "开放问题"
"openIssues": "开放问题",
"enabled": "Enabled",
"disabled": "已禁用"
},
"priority": {
"low": "低",

View File

@@ -0,0 +1,51 @@
{
"page": {
"title": "执行监控",
"subtitle": "查看实时执行状态和历史记录"
},
"currentExecution": {
"title": "当前执行",
"noExecution": "当前没有正在执行的工作流",
"expand": "展开",
"collapse": "收起"
},
"stats": {
"title": "统计数据",
"totalExecutions": "总执行次数",
"successRate": "成功率",
"avgDuration": "平均时长",
"nodeSuccessRate": "节点成功率"
},
"history": {
"title": "执行历史",
"empty": "暂无执行历史",
"tabs": {
"byWorkflow": "按工作流",
"timeline": "时间线",
"list": "列表视图"
}
},
"filters": {
"workflow": "工作流",
"status": "状态",
"dateRange": "日期范围",
"all": "所有工作流",
"allStatus": "所有状态"
},
"execution": {
"status": {
"pending": "待执行",
"running": "执行中",
"paused": "已暂停",
"completed": "已完成",
"failed": "已失败"
},
"duration": "时长",
"startedAt": "开始时间",
"completedAt": "完成时间",
"nodes": "节点",
"progress": "进度",
"logs": "日志",
"viewDetails": "查看详情"
}
}

View File

@@ -0,0 +1,57 @@
{
"title": "文件浏览器",
"description": "浏览和搜索项目文件",
"viewMode": {
"tree": "树形",
"list": "列表",
"compact": "紧凑"
},
"sortOrder": {
"name": "名称",
"size": "大小",
"modified": "修改时间",
"type": "类型"
},
"tree": {
"loading": "正在加载文件树...",
"stats": "{files} 个项目",
"empty": "未找到文件",
"error": "加载文件树失败"
},
"preview": {
"loading": "正在加载文件内容...",
"errorTitle": "加载文件错误",
"emptyTitle": "未选择文件",
"emptyMessage": "从树形视图中选择一个文件以预览其内容",
"binaryTitle": "二进制文件",
"binaryMessage": "此文件类型无法预览",
"tooLargeTitle": "文件过大",
"tooLargeMessage": "文件超过预览限制 {size}",
"copy": "复制到剪贴板",
"lastModified": "最后修改: {time}"
},
"toolbar": {
"searchPlaceholder": "搜索文件...",
"selectRoot": "选择目录",
"rootDirectory": "根目录",
"noRoots": "没有可用目录",
"viewMode": "视图模式",
"sortBy": "排序方式",
"moreOptions": "更多选项",
"options": "选项",
"showHidden": "显示隐藏文件",
"expandAll": "全部展开",
"collapseAll": "全部折叠"
},
"errors": {
"loadFailed": "加载文件树失败",
"loadFileFailed": "加载文件内容失败",
"searchFailed": "搜索失败",
"networkError": "发生网络错误"
},
"context": {
"hasClaudeMd": "包含 CLAUDE.md 上下文",
"gitRoot": "Git 仓库根目录",
"workspace": "工作区目录"
}
}

View File

@@ -40,5 +40,8 @@
"empty": {
"title": "未找到任务",
"message": "没有匹配当前筛选条件的修复任务。"
},
"phase": {
"execution": "执行"
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "图浏览器",
"description": "可视化代码依赖关系",
"filters": {
"title": "筛选",
"nodeTypes": "节点类型",
"edgeTypes": "边类型",
"selectNodeTypes": "选择节点类型",
"selectEdgeTypes": "选择边类型",
"searchPlaceholder": "搜索节点...",
"showOnlyIssues": "仅显示问题",
"showIsolatedNodes": "显示孤立节点",
"minComplexity": "最小复杂度",
"maxDepth": "最大深度",
"reset": "重置"
},
"nodeTypes": {
"component": "组件",
"module": "模块",
"function": "函数",
"class": "类",
"interface": "接口",
"variable": "变量",
"file": "文件",
"folder": "文件夹",
"dependency": "依赖",
"api": "API",
"database": "数据库",
"service": "服务",
"hook": "钩子",
"utility": "工具",
"unknown": "未知"
},
"edgeTypes": {
"imports": "导入",
"exports": "导出",
"extends": "继承",
"implements": "实现",
"uses": "使用",
"dependsOn": "依赖",
"calls": "调用",
"instantiates": "实例化",
"contains": "包含",
"relatedTo": "相关",
"dataFlow": "数据流",
"event": "事件",
"unknown": "未知"
},
"actions": {
"fitView": "适应屏幕",
"refresh": "刷新",
"resetFilters": "重置筛选",
"export": "导出",
"settings": "设置",
"zoomIn": "放大",
"zoomOut": "缩小"
},
"legend": {
"title": "图例",
"nodeTypes": "节点类型",
"edgeTypes": "边类型",
"component": "组件",
"module": "模块",
"class": "类",
"function": "函数",
"variable": "变量",
"imports": "导入(实线)",
"calls": "调用(绿色线)",
"extends": "继承(虚线)"
},
"sidebar": {
"title": "图浏览器",
"nodeDetails": "节点详情",
"hasIssues": "存在问题",
"filePath": "文件路径",
"lineNumber": "行号",
"category": "类别",
"lineCount": "代码行数",
"documentation": "文档说明",
"tags": "标签",
"issues": "问题",
"instructions": "点击节点查看详情。使用工具栏筛选节点和边。"
},
"status": {
"nodes": "节点",
"edges": "边",
"loading": "加载图中...",
"updating": "更新中...",
"filtered": "显示 {count} / {total} 个节点"
},
"error": {
"loading": "加载图失败:{message}",
"empty": "没有可用的图数据",
"unknown": "发生未知错误"
},
"empty": "没有可用的图数据。尝试调整筛选器或刷新页面。"
}

View File

@@ -30,6 +30,26 @@
"message": "创建问题以跟踪错误或功能请求。"
}
},
"help": {
"gettingStarted": {
"title": "入门指南",
"description": "了解 CCW 仪表盘和工作流管理的基础知识",
"heading": "CCW 入门指南"
},
"orchestratorGuide": {
"title": "编排器指南",
"description": "掌握可视化拖放式工作流编辑器"
},
"sessionsManagement": {
"title": "会话管理",
"description": "了解工作流会话和任务跟踪"
},
"cliIntegration": {
"title": "CLI 集成",
"description": "使用 CCW 命令和 CLI 工具集成",
"heading": "CLI 集成"
}
},
"errors": {
"loadFailed": "加载仪表板数据失败",
"retry": "重试"

View File

@@ -0,0 +1,45 @@
{
"title": "代码索引",
"description": "管理代码搜索索引以实现更快的导航和代码发现",
"sections": {
"status": "状态",
"actions": "操作",
"settings": "设置"
},
"status": {
"idle": "空闲",
"building": "构建中",
"completed": "就绪",
"failed": "失败"
},
"actions": {
"rebuild": "重建索引",
"rebuildFull": "完全重建",
"cancel": "取消"
},
"stats": {
"totalFiles": "总文件数",
"totalFilesDesc": "索引中的文件数",
"lastUpdated": "最后更新",
"lastUpdatedDesc": "上次构建索引的时间",
"buildTime": "构建时间",
"buildTimeDesc": "上次构建所花费的时间",
"never": "从未索引"
},
"time": {
"justNow": "刚刚",
"minutesAgo": "{value}分钟前",
"hoursAgo": "{value}小时前",
"daysAgo": "{value}天前"
},
"errors": {
"rebuildFailed": "索引重建失败",
"loadFailed": "加载索引状态失败"
},
"settings": {
"autoRebuild": "自动重建",
"autoRebuildDesc": "代码更改时自动重建索引",
"rebuildInterval": "重建间隔",
"rebuildIntervalDesc": "检查代码更改的频率"
}
}

View File

@@ -21,7 +21,19 @@ import reviewSession from './review-session.json';
import sessionDetail from './session-detail.json';
import skills from './skills.json';
import cliManager from './cli-manager.json';
import cliMonitor from './cli-monitor.json';
import mcpManager from './mcp-manager.json';
import theme from './theme.json';
import executionMonitor from './execution-monitor.json';
import cliHooks from './cli-hooks.json';
import index from './index.json';
import rules from './rules.json';
import prompts from './prompts.json';
import explorer from './explorer.json';
import graph from './graph.json';
import notification from './notification.json';
import notifications from './notifications.json';
import workspace from './workspace.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -66,5 +78,17 @@ export default {
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
...flattenMessages(cliMonitor, 'cliMonitor'),
...flattenMessages(mcpManager, 'mcp'),
...flattenMessages(theme, 'theme'),
...flattenMessages(cliHooks, 'cliHooks'),
...flattenMessages(executionMonitor, 'executionMonitor'),
...flattenMessages(index, 'index'),
...flattenMessages(rules, 'rules'),
...flattenMessages(prompts, 'prompts'),
...flattenMessages(explorer, 'explorer'),
...flattenMessages(graph, 'graph'),
...flattenMessages(notification, 'notificationPanel'),
...flattenMessages(notifications, 'notifications'),
...flattenMessages(workspace, 'workspace'),
} as Record<string, string>;

View File

@@ -19,6 +19,8 @@
"title": "此会话中没有任务",
"message": "此会话尚不包含任何任务。"
},
"untitled": "无标题任务",
"discussionTopic": "讨论主题",
"notFound": {
"title": "未找到轻量任务",
"message": "无法找到请求的轻量任务会话。"

View File

@@ -1,6 +1,10 @@
{
"title": "MCP 服务器",
"description": "管理模型上下文协议 (MCP) 服务器以实现跨 CLI 集成",
"mode": {
"claude": "Claude",
"codex": "Codex"
},
"scope": {
"global": "全局",
"project": "项目"
@@ -18,6 +22,11 @@
"command": "命令",
"args": "参数",
"env": "环境变量",
"codex": {
"configPath": "配置路径",
"readOnly": "只读",
"readOnlyNotice": "Codex MCP 服务器通过 config.toml 管理,无法在此处编辑。"
},
"filters": {
"all": "全部",
"searchPlaceholder": "按名称或命令搜索服务器..."
@@ -33,5 +42,89 @@
"emptyState": {
"title": "未找到 MCP 服务器",
"message": "添加 MCP 服务器以启用与 Claude、Codex 和 Qwen 等工具的跨 CLI 集成。"
},
"dialog": {
"addTitle": "添加 MCP 服务器",
"editTitle": "编辑 MCP 服务器 \"{name}\"",
"form": {
"template": "模板",
"templatePlaceholder": "选择模板以预填充表单",
"name": "服务器名称",
"namePlaceholder": "例如my-mcp-server",
"command": "命令",
"commandPlaceholder": "例如npx、python、node",
"args": "参数",
"argsPlaceholder": "逗号分隔的参数,例如:-v, --option=value",
"argsHint": "使用逗号分隔多个参数",
"env": "环境变量",
"envPlaceholder": "键=值对(每行一个),例如:\nAPI_KEY=your_key\nDEBUG=true",
"envHint": "每行输入一个键=值对",
"scope": "作用域",
"enabled": "启用此服务器"
},
"templates": {
"npx-stdio": "NPX STDIO",
"python-stdio": "Python STDIO",
"sse-server": "SSE 服务器"
},
"validation": {
"nameRequired": "服务器名称不能为空",
"nameExists": "已存在同名服务器",
"commandRequired": "命令不能为空"
},
"actions": {
"save": "保存",
"saving": "保存中...",
"cancel": "取消"
}
},
"ccw": {
"title": "CCW MCP 服务器",
"description": "用于 CCW 文件操作和内存管理的特殊内置 MCP 服务器",
"status": {
"installed": "已安装",
"notInstalled": "未安装",
"special": "内置"
},
"tools": {
"label": "可用工具",
"core": "核心",
"write_file": {
"name": "write_file",
"desc": "写入或创建新文件"
},
"edit_file": {
"name": "edit_file",
"desc": "编辑或替换文件内容"
},
"read_file": {
"name": "read_file",
"desc": "读取文件内容"
},
"core_memory": {
"name": "core_memory",
"desc": "管理核心内存条目"
}
},
"paths": {
"label": "路径配置",
"projectRoot": "项目根目录",
"projectRootPlaceholder": "例如D:\\Projects\\MyProject",
"allowedDirs": "允许的目录",
"allowedDirsPlaceholder": "目录1,目录2,目录3",
"allowedDirsHint": "逗号分隔的允许目录列表",
"disableSandbox": "禁用沙箱"
},
"actions": {
"enableAll": "全部启用",
"disableAll": "全部禁用",
"install": "安装 CCW MCP",
"installing": "安装中...",
"uninstall": "卸载",
"uninstalling": "卸载中...",
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
"saveConfig": "保存配置",
"saving": "保存中..."
}
}
}

View File

@@ -11,11 +11,16 @@
"skills": "技能",
"commands": "命令",
"memory": "记忆",
"prompts": "提示历史",
"settings": "设置",
"mcp": "MCP 服务器",
"endpoints": "CLI 端点",
"installations": "安装",
"help": "帮助"
"help": "帮助",
"hooks": "Hooks",
"rules": "规则",
"explorer": "文件浏览器",
"graph": "图浏览器"
},
"sidebar": {
"collapse": "收起",

View File

@@ -0,0 +1,9 @@
{
"title": "通知",
"markAllRead": "全部已读",
"clearAll": "清空全部",
"showMore": "显示更多",
"showLess": "收起",
"empty": "暂无通知",
"emptyHint": "通知将显示在这里"
}

View File

@@ -0,0 +1,18 @@
{
"title": "通知",
"empty": "暂无通知",
"emptyHint": "通知将显示在这里",
"markAllRead": "全部已读",
"clearAll": "清空全部",
"showMore": "显示更多",
"showLess": "收起",
"systemNotifications": "系统通知",
"systemNotificationsDesc": "为重要事件显示浏览器原生通知",
"justNow": "刚刚",
"minutesAgo": "{0}分钟前",
"hoursAgo": "{0}小时前",
"daysAgo": "{0}天前",
"oneMinuteAgo": "1分钟前",
"oneHourAgo": "1小时前",
"oneDayAgo": "1天前"
}

View File

@@ -59,5 +59,135 @@
"timeline": "时间线",
"variables": "变量",
"realtime": "实时更新"
},
"notifications": {
"flowCreated": "流程已创建",
"flowSaved": "流程已保存",
"saveFailed": "保存失败",
"flowLoaded": "流程已加载",
"loadFailed": "加载失败",
"flowDeleted": "流程已删除",
"deleteFailed": "删除失败",
"flowDuplicated": "流程已复制",
"duplicateFailed": "复制失败"
},
"templateLibrary": {
"title": "模板库",
"description": "浏览和导入工作流模板,或将当前流程导出为模板。",
"searchPlaceholder": "搜索模板...",
"allCategories": "全部",
"exportCurrent": "导出当前流程",
"close": "关闭",
"errors": {
"loadFailed": "加载模板失败"
},
"emptyState": {
"title": "未找到模板",
"searchSuggestion": "尝试不同的搜索查询"
},
"footer": {
"templateCount": "{count} 个模板"
},
"card": {
"nodes": "个节点",
"import": "导入",
"delete": "删除"
},
"exportDialog": {
"title": "导出为模板",
"description": "将此流程保存为可重用的模板到您的库中。",
"fields": {
"name": "名称",
"namePlaceholder": "模板名称",
"description": "描述",
"descriptionPlaceholder": "此模板的简要描述",
"category": "类别",
"categoryPlaceholder": "例如: 开发、测试、部署",
"tags": "标签 (逗号分隔)",
"tagsPlaceholder": "例如: react、testing、ci/cd"
},
"actions": {
"cancel": "取消",
"export": "导出"
}
}
},
"toolbar": {
"placeholder": "流程名称",
"unsavedChanges": "未保存的更改",
"new": "新建",
"save": "保存",
"load": "加载",
"export": "导出",
"templates": "模板",
"savedFlows": "已保存的流程 ({count})",
"loading": "加载中...",
"noSavedFlows": "无已保存的流程",
"duplicate": "复制",
"delete": "删除"
},
"palette": {
"title": "节点面板",
"open": "打开节点面板",
"collapse": "折叠面板",
"instructions": "将节点拖到画布上以将其添加到您的工作流中",
"nodeTypes": "节点类型",
"tipLabel": "提示:",
"tip": "通过从输出拖动到输入句柄来连接节点"
},
"propertyPanel": {
"title": "属性",
"open": "打开属性面板",
"close": "关闭面板",
"selectNode": "选择节点以编辑其属性",
"deleteNode": "删除节点",
"placeholders": {
"nodeLabel": "节点标签",
"commandName": "/命令名称",
"commandArgs": "命令参数",
"timeout": "60000",
"path": "/文件路径",
"content": "文件内容...",
"destinationPath": "/目标路径",
"variableName": "变量名称",
"condition": "例如: result.success === true",
"trueLabel": "真",
"falseLabel": "假"
},
"labels": {
"label": "标签",
"command": "命令",
"arguments": "参数",
"executionMode": "执行模式",
"onError": "出错时",
"timeout": "超时 (毫秒)",
"operation": "操作",
"path": "路径",
"content": "内容",
"destinationPath": "目标路径",
"outputVariable": "输出变量",
"addToContext": "添加到上下文",
"condition": "条件",
"trueLabel": "真标签",
"falseLabel": "假标签",
"joinMode": "加入模式",
"failFast": "快速失败 (首次错误时停止所有分支)"
},
"options": {
"modeAnalysis": "分析 (只读)",
"modeWrite": "写入 (修改文件)",
"errorStop": "停止执行",
"errorContinue": "继续",
"errorRetry": "重试",
"operationRead": "读取",
"operationWrite": "写入",
"operationAppend": "追加",
"operationDelete": "删除",
"operationCopy": "复制",
"operationMove": "移动",
"joinModeAll": "等待所有分支",
"joinModeAny": "任一分支完成时完成",
"joinModeNone": "无同步"
}
}
}

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