diff --git a/.claude/agents/conceptual-planning-agent.md b/.claude/agents/conceptual-planning-agent.md index 5e4e0bff..2a06e0a8 100644 --- a/.claude/agents/conceptual-planning-agent.md +++ b/.claude/agents/conceptual-planning-agent.md @@ -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 diff --git a/.claude/agents/test-fix-agent.md b/.claude/agents/test-fix-agent.md index 0c770fdb..aa4166af 100644 --- a/.claude/agents/test-fix-agent.md +++ b/.claude/agents/test-fix-agent.md @@ -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 diff --git a/.codex/agents/conceptual-planning-agent.md b/.codex/agents/conceptual-planning-agent.md index 5e4e0bff..2a06e0a8 100644 --- a/.codex/agents/conceptual-planning-agent.md +++ b/.codex/agents/conceptual-planning-agent.md @@ -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 diff --git a/.codex/agents/test-fix-agent.md b/.codex/agents/test-fix-agent.md index addd7a0d..fbe13fd5 100644 --- a/.codex/agents/test-fix-agent.md +++ b/.codex/agents/test-fix-agent.md @@ -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 diff --git a/ccw/frontend/scripts/validate-translations.ts b/ccw/frontend/scripts/validate-translations.ts index f4f3e2eb..88bff68f 100644 --- a/ccw/frontend/scripts/validate-translations.ts +++ b/ccw/frontend/scripts/validate-translations.ts @@ -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, prefix = ''): string[] { - const keys: string[] = []; +function flattenObject(obj: Record, prefix = ''): Map { + const map = new Map(); 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, fullKey)); + const nestedMap = flattenObject(value as Record, 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 { } /** - * 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 { const localeDir = join(LOCALES_DIR, locale); - const keys: string[] = []; + const map = new Map(); 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(''); } diff --git a/ccw/frontend/src/App.tsx b/ccw/frontend/src/App.tsx index 30f978a4..01fff7dd 100644 --- a/ccw/frontend/src/App.tsx +++ b/ccw/frontend/src/App.tsx @@ -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 ( + ); } +/** + * 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; diff --git a/ccw/frontend/src/components/hook/EventGroup.tsx b/ccw/frontend/src/components/hook/EventGroup.tsx new file mode 100644 index 00000000..2df7617a --- /dev/null +++ b/ccw/frontend/src/components/hook/EventGroup.tsx @@ -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>(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 ( + + {/* Event Header */} +
+
+
+
+ +
+
+

+ {formatMessage({ id: `cliHooks.trigger.${eventType}` })} +

+

+ {formatMessage({ id: 'cliHooks.stats.count' }, { + enabled: enabledCount, + total: totalCount + })} +

+
+
+
+ + {totalCount} + + {isExpanded ? ( + + ) : ( + + )} +
+
+
+ + {/* Hooks List */} + {isExpanded && ( +
+ {totalCount === 0 ? ( +
+

{formatMessage({ id: 'cliHooks.empty.noHooksInEvent' })}

+
+ ) : ( + <> + {/* Expand/Collapse All */} + {totalCount > 1 && ( +
+ + / + +
+ )} + + {/* Hook Cards */} +
+ {hooks.map((hook) => ( + handleToggleHookExpand(hook.name)} + onToggle={onHookToggle} + onEdit={onHookEdit} + onDelete={onHookDelete} + /> + ))} +
+ + )} +
+ )} +
+ ); +} + +export default EventGroup; diff --git a/ccw/frontend/src/components/hook/HookCard.tsx b/ccw/frontend/src/components/hook/HookCard.tsx new file mode 100644 index 00000000..90c769fc --- /dev/null +++ b/ccw/frontend/src/components/hook/HookCard.tsx @@ -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 ( + + {/* Header */} +
+
+
+
+ +
+
+
+ + {hook.name} + + + {getTriggerIcon(hook.trigger)} + {formatMessage({ id: `cliHooks.trigger.${hook.trigger}` })} + + + {hook.enabled + ? formatMessage({ id: 'common.status.enabled' }) + : formatMessage({ id: 'common.status.disabled' }) + } + +
+ {hook.description && ( +

+ {hook.description} +

+ )} +
+
+ + {/* Action Buttons */} +
+ + + + +
+
+
+ + {/* Expanded Content */} + {isExpanded && ( +
+ {hook.description && ( +
+ +

{hook.description}

+
+ )} + +
+ +

+ {hook.matcher || formatMessage({ id: 'cliHooks.allTools' })} +

+
+ +
+ +

+ {hook.command || hook.script || 'N/A'} +

+
+
+ )} +
+ ); +} + +export default HookCard; diff --git a/ccw/frontend/src/components/hook/HookFormDialog.tsx b/ccw/frontend/src/components/hook/HookFormDialog.tsx new file mode 100644 index 00000000..ec76a976 --- /dev/null +++ b/ccw/frontend/src/components/hook/HookFormDialog.tsx @@ -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; +} + +// ========== 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({ + name: '', + description: '', + trigger: 'PostToolUse', + matcher: '', + command: '', + }); + const [errors, setErrors] = useState({}); + 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 ( + + + + + {mode === 'create' + ? formatMessage({ id: 'cliHooks.dialog.createTitle' }) + : formatMessage({ id: 'cliHooks.dialog.editTitle' }, { hookName: hook?.name }) + } + + + +
+ {/* Name */} +
+ + handleFieldChange('name', e.target.value)} + placeholder={formatMessage({ id: 'cliHooks.form.namePlaceholder' })} + className="mt-1" + error={!!errors.name} + /> + {errors.name && ( +

+ {formatMessage({ id: `cliHooks.${errors.name}` })} +

+ )} +
+ + {/* Description */} +
+ +