mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
194
ccw/frontend/src/components/hook/EventGroup.tsx
Normal file
194
ccw/frontend/src/components/hook/EventGroup.tsx
Normal 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;
|
||||
238
ccw/frontend/src/components/hook/HookCard.tsx
Normal file
238
ccw/frontend/src/components/hook/HookCard.tsx
Normal 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;
|
||||
289
ccw/frontend/src/components/hook/HookFormDialog.tsx
Normal file
289
ccw/frontend/src/components/hook/HookFormDialog.tsx
Normal 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;
|
||||
268
ccw/frontend/src/components/hook/HookQuickTemplates.tsx
Normal file
268
ccw/frontend/src/components/hook/HookQuickTemplates.tsx
Normal 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;
|
||||
842
ccw/frontend/src/components/hook/HookWizard.tsx
Normal file
842
ccw/frontend/src/components/hook/HookWizard.tsx
Normal 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]);
|
||||
}
|
||||
19
ccw/frontend/src/components/hook/index.ts
Normal file
19
ccw/frontend/src/components/hook/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
433
ccw/frontend/src/components/mcp/CcwToolsMcpCard.tsx
Normal file
433
ccw/frontend/src/components/mcp/CcwToolsMcpCard.tsx
Normal 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;
|
||||
76
ccw/frontend/src/components/mcp/CliModeToggle.tsx
Normal file
76
ccw/frontend/src/components/mcp/CliModeToggle.tsx
Normal 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;
|
||||
152
ccw/frontend/src/components/mcp/CodexMcpCard.tsx
Normal file
152
ccw/frontend/src/components/mcp/CodexMcpCard.tsx
Normal 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;
|
||||
520
ccw/frontend/src/components/mcp/McpServerDialog.tsx
Normal file
520
ccw/frontend/src/components/mcp/McpServerDialog.tsx
Normal 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;
|
||||
405
ccw/frontend/src/components/notification/NotificationPanel.tsx
Normal file
405
ccw/frontend/src/components/notification/NotificationPanel.tsx
Normal 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;
|
||||
7
ccw/frontend/src/components/notification/index.ts
Normal file
7
ccw/frontend/src/components/notification/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ========================================
|
||||
// Notification Components Index
|
||||
// ========================================
|
||||
// Centralized exports for notification components
|
||||
|
||||
export { NotificationPanel } from './NotificationPanel';
|
||||
export type { NotificationPanelProps } from './NotificationPanel';
|
||||
519
ccw/frontend/src/components/shared/CliStreamMonitor.tsx
Normal file
519
ccw/frontend/src/components/shared/CliStreamMonitor.tsx
Normal 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;
|
||||
@@ -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,50 +313,31 @@ 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);
|
||||
|
||||
// Build concatenated prompt
|
||||
const concatenatedPrompt = React.useMemo(() => {
|
||||
if (!execution?.turns) return '';
|
||||
return buildConcatenatedPrompt(execution, concatFormat, formatMessage);
|
||||
}, [execution, concatFormat, formatMessage]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 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 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine historical + streaming
|
||||
return [...historical, ...outputs];
|
||||
}, [execution, outputs]);
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = React.useMemo(() => {
|
||||
if (!execution?.turns) return 0;
|
||||
@@ -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"
|
||||
) : 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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6">
|
||||
<TabsContent
|
||||
value="prompt"
|
||||
className="mt-4 h-full overflow-y-auto m-0"
|
||||
>
|
||||
<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}
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="details"
|
||||
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="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>
|
||||
<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>
|
||||
<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 className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
{formatMessage({ id: 'cli-manager.streamPanel.noDetails' })}
|
||||
</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>
|
||||
</div>
|
||||
</Tabs>
|
||||
) : null}
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
338
ccw/frontend/src/components/shared/ExplorerToolbar.tsx
Normal file
338
ccw/frontend/src/components/shared/ExplorerToolbar.tsx
Normal 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;
|
||||
325
ccw/frontend/src/components/shared/FilePreview.tsx
Normal file
325
ccw/frontend/src/components/shared/FilePreview.tsx
Normal 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;
|
||||
257
ccw/frontend/src/components/shared/GraphSidebar.tsx
Normal file
257
ccw/frontend/src/components/shared/GraphSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
ccw/frontend/src/components/shared/GraphToolbar.tsx
Normal file
225
ccw/frontend/src/components/shared/GraphToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
ccw/frontend/src/components/shared/IndexManager.tsx
Normal file
227
ccw/frontend/src/components/shared/IndexManager.tsx
Normal 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;
|
||||
311
ccw/frontend/src/components/shared/InsightsPanel.tsx
Normal file
311
ccw/frontend/src/components/shared/InsightsPanel.tsx
Normal 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;
|
||||
204
ccw/frontend/src/components/shared/PromptCard.tsx
Normal file
204
ccw/frontend/src/components/shared/PromptCard.tsx
Normal 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;
|
||||
89
ccw/frontend/src/components/shared/PromptStats.tsx
Normal file
89
ccw/frontend/src/components/shared/PromptStats.tsx
Normal 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;
|
||||
229
ccw/frontend/src/components/shared/RuleCard.tsx
Normal file
229
ccw/frontend/src/components/shared/RuleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
479
ccw/frontend/src/components/shared/RuleDialog.tsx
Normal file
479
ccw/frontend/src/components/shared/RuleDialog.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
298
ccw/frontend/src/components/shared/TreeView.tsx
Normal file
298
ccw/frontend/src/components/shared/TreeView.tsx
Normal 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;
|
||||
81
ccw/frontend/src/components/shared/index.ts
Normal file
81
ccw/frontend/src/components/shared/index.ts
Normal 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';
|
||||
27
ccw/frontend/src/components/ui/Checkbox.tsx
Normal file
27
ccw/frontend/src/components/ui/Checkbox.tsx
Normal 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 };
|
||||
23
ccw/frontend/src/components/ui/Label.tsx
Normal file
23
ccw/frontend/src/components/ui/Label.tsx
Normal 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 };
|
||||
25
ccw/frontend/src/components/ui/Progress.tsx
Normal file
25
ccw/frontend/src/components/ui/Progress.tsx
Normal 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 };
|
||||
50
ccw/frontend/src/components/ui/Switch.tsx
Normal file
50
ccw/frontend/src/components/ui/Switch.tsx
Normal 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;
|
||||
26
ccw/frontend/src/components/ui/Textarea.tsx
Normal file
26
ccw/frontend/src/components/ui/Textarea.tsx
Normal 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 };
|
||||
277
ccw/frontend/src/components/workspace/WorkspaceSelector.tsx
Normal file
277
ccw/frontend/src/components/workspace/WorkspaceSelector.tsx
Normal 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;
|
||||
6
ccw/frontend/src/components/workspace/index.ts
Normal file
6
ccw/frontend/src/components/workspace/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ========================================
|
||||
// Workspace Components
|
||||
// ========================================
|
||||
|
||||
export { WorkspaceSelector } from './WorkspaceSelector';
|
||||
export type { WorkspaceSelectorProps } from './WorkspaceSelector';
|
||||
@@ -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';
|
||||
202
ccw/frontend/src/hooks/useActiveCliExecutions.ts
Normal file
202
ccw/frontend/src/hooks/useActiveCliExecutions.ts
Normal 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 });
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
if (projectPath) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: dashboardStatsKeys.detail(),
|
||||
queryKey: workspaceQueryKeys.projectOverview(projectPath),
|
||||
queryFn: fetchDashboardStats,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
450
ccw/frontend/src/hooks/useFileExplorer.ts
Normal file
450
ccw/frontend/src/hooks/useFileExplorer.ts
Normal 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;
|
||||
308
ccw/frontend/src/hooks/useGraphData.ts
Normal file
308
ccw/frontend/src/hooks/useGraphData.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
143
ccw/frontend/src/hooks/useIndex.ts
Normal file
143
ccw/frontend/src/hooks/useIndex.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
254
ccw/frontend/src/hooks/usePromptHistory.ts
Normal file
254
ccw/frontend/src/hooks/usePromptHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
206
ccw/frontend/src/hooks/useSystemNotifications.ts
Normal file
206
ccw/frontend/src/hooks/useSystemNotifications.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
156
ccw/frontend/src/hooks/useWebSocketNotifications.ts
Normal file
156
ccw/frontend/src/hooks/useWebSocketNotifications.ts
Normal 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;
|
||||
100
ccw/frontend/src/hooks/useWorkspaceQueryKeys.ts
Normal file
100
ccw/frontend/src/hooks/useWorkspaceQueryKeys.ts
Normal 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>;
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
|
||||
95
ccw/frontend/src/lib/queryKeys.ts
Normal file
95
ccw/frontend/src/lib/queryKeys.ts
Normal 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,
|
||||
};
|
||||
160
ccw/frontend/src/locales/en/cli-hooks.json
Normal file
160
ccw/frontend/src/locales/en/cli-hooks.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
18
ccw/frontend/src/locales/en/cli-monitor.json
Normal file
18
ccw/frontend/src/locales/en/cli-monitor.json
Normal 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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
51
ccw/frontend/src/locales/en/execution-monitor.json
Normal file
51
ccw/frontend/src/locales/en/execution-monitor.json
Normal 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"
|
||||
}
|
||||
}
|
||||
57
ccw/frontend/src/locales/en/explorer.json
Normal file
57
ccw/frontend/src/locales/en/explorer.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,8 @@
|
||||
"empty": {
|
||||
"title": "No Tasks Found",
|
||||
"message": "No fix tasks match the current filter."
|
||||
},
|
||||
"phase": {
|
||||
"execution": "Execution"
|
||||
}
|
||||
}
|
||||
|
||||
97
ccw/frontend/src/locales/en/graph.json
Normal file
97
ccw/frontend/src/locales/en/graph.json
Normal 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."
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
45
ccw/frontend/src/locales/en/index.json
Normal file
45
ccw/frontend/src/locales/en/index.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
9
ccw/frontend/src/locales/en/notification.json
Normal file
9
ccw/frontend/src/locales/en/notification.json
Normal 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"
|
||||
}
|
||||
18
ccw/frontend/src/locales/en/notifications.json
Normal file
18
ccw/frontend/src/locales/en/notifications.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
ccw/frontend/src/locales/en/prompts.json
Normal file
74
ccw/frontend/src/locales/en/prompts.json
Normal 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"
|
||||
}
|
||||
}
|
||||
79
ccw/frontend/src/locales/en/rules.json
Normal file
79
ccw/frontend/src/locales/en/rules.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
21
ccw/frontend/src/locales/en/theme.json
Normal file
21
ccw/frontend/src/locales/en/theme.json
Normal 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}"
|
||||
}
|
||||
29
ccw/frontend/src/locales/en/workspace.json
Normal file
29
ccw/frontend/src/locales/en/workspace.json
Normal 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..."
|
||||
}
|
||||
}
|
||||
160
ccw/frontend/src/locales/zh/cli-hooks.json
Normal file
160
ccw/frontend/src/locales/zh/cli-hooks.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "状态"
|
||||
}
|
||||
}
|
||||
|
||||
18
ccw/frontend/src/locales/zh/cli-monitor.json
Normal file
18
ccw/frontend/src/locales/zh/cli-monitor.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "CLI 流式监控",
|
||||
"searchPlaceholder": "搜索输出...",
|
||||
"noExecutions": "没有正在执行的 CLI 任务",
|
||||
"noExecutionsHint": "启动 CLI 命令以查看实时输出",
|
||||
"selectExecution": "选择一个任务以查看输出",
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"error": "错误"
|
||||
},
|
||||
"recovered": "已恢复",
|
||||
"lines": "行",
|
||||
"autoScroll": "自动滚动",
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"close": "关闭",
|
||||
"refresh": "刷新"
|
||||
}
|
||||
@@ -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": "低",
|
||||
|
||||
51
ccw/frontend/src/locales/zh/execution-monitor.json
Normal file
51
ccw/frontend/src/locales/zh/execution-monitor.json
Normal 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": "查看详情"
|
||||
}
|
||||
}
|
||||
57
ccw/frontend/src/locales/zh/explorer.json
Normal file
57
ccw/frontend/src/locales/zh/explorer.json
Normal 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": "工作区目录"
|
||||
}
|
||||
}
|
||||
@@ -40,5 +40,8 @@
|
||||
"empty": {
|
||||
"title": "未找到任务",
|
||||
"message": "没有匹配当前筛选条件的修复任务。"
|
||||
},
|
||||
"phase": {
|
||||
"execution": "执行"
|
||||
}
|
||||
}
|
||||
|
||||
97
ccw/frontend/src/locales/zh/graph.json
Normal file
97
ccw/frontend/src/locales/zh/graph.json
Normal 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": "没有可用的图数据。尝试调整筛选器或刷新页面。"
|
||||
}
|
||||
@@ -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": "重试"
|
||||
|
||||
45
ccw/frontend/src/locales/zh/index.json
Normal file
45
ccw/frontend/src/locales/zh/index.json
Normal 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": "检查代码更改的频率"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"title": "此会话中没有任务",
|
||||
"message": "此会话尚不包含任何任务。"
|
||||
},
|
||||
"untitled": "无标题任务",
|
||||
"discussionTopic": "讨论主题",
|
||||
"notFound": {
|
||||
"title": "未找到轻量任务",
|
||||
"message": "无法找到请求的轻量任务会话。"
|
||||
|
||||
@@ -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": "保存中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"skills": "技能",
|
||||
"commands": "命令",
|
||||
"memory": "记忆",
|
||||
"prompts": "提示历史",
|
||||
"settings": "设置",
|
||||
"mcp": "MCP 服务器",
|
||||
"endpoints": "CLI 端点",
|
||||
"installations": "安装",
|
||||
"help": "帮助"
|
||||
"help": "帮助",
|
||||
"hooks": "Hooks",
|
||||
"rules": "规则",
|
||||
"explorer": "文件浏览器",
|
||||
"graph": "图浏览器"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起",
|
||||
|
||||
9
ccw/frontend/src/locales/zh/notification.json
Normal file
9
ccw/frontend/src/locales/zh/notification.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "通知",
|
||||
"markAllRead": "全部已读",
|
||||
"clearAll": "清空全部",
|
||||
"showMore": "显示更多",
|
||||
"showLess": "收起",
|
||||
"empty": "暂无通知",
|
||||
"emptyHint": "通知将显示在这里"
|
||||
}
|
||||
18
ccw/frontend/src/locales/zh/notifications.json
Normal file
18
ccw/frontend/src/locales/zh/notifications.json
Normal 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天前"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user