feat: add Accordion component for UI and Zustand store for coordinator management

- Implemented Accordion component using Radix UI for collapsible sections.
- Created Zustand store to manage coordinator execution state, command chains, logs, and interactive questions.
- Added validation tests for CLI settings type definitions, ensuring type safety and correct behavior of helper functions.
This commit is contained in:
catlog22
2026-02-03 10:02:40 +08:00
parent bcb4af3ba0
commit 5483a72e9f
82 changed files with 6156 additions and 7605 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.11.4",
"@hello-pangea/dnd": "^18.0.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -1392,6 +1393,37 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",

View File

@@ -19,6 +19,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.11.4",
"@hello-pangea/dnd": "^18.0.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",

View File

@@ -11,6 +11,7 @@ import { router } from './router';
import queryClient from './lib/query-client';
import type { Locale } from './lib/i18n';
import { useWorkflowStore } from '@/stores/workflowStore';
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
interface AppProps {
locale: Locale;
@@ -26,6 +27,7 @@ function App({ locale, messages }: AppProps) {
<IntlProvider locale={locale} messages={messages}>
<QueryClientProvider client={queryClient}>
<QueryInvalidator />
<CliExecutionSync />
<RouterProvider router={router} />
</QueryClientProvider>
</IntlProvider>
@@ -57,4 +59,19 @@ function QueryInvalidator() {
return null;
}
/**
* CLI Execution Sync component
* Syncs active CLI executions in the background to keep the count updated in Header
*/
function CliExecutionSync() {
// Always sync active CLI executions with a longer polling interval
// This ensures the activeCliCount badge in Header shows correct count on initial load
useActiveCliExecutions(
true, // enabled: always sync
15000 // refetchInterval: 15 seconds (longer than monitor's 5 seconds to reduce load)
);
return null;
}
export default App;

View File

@@ -119,7 +119,7 @@ function CliSettingsCard({
)}
{cliSettings.settings.includeCoAuthoredBy !== undefined && (
<span>
Co-authored: {cliSettings.settings.includeCoAuthoredBy ? 'Yes' : 'No'}
{formatMessage({ id: 'apiSettings.cliSettings.coAuthoredBy' })}: {formatMessage({ id: cliSettings.settings.includeCoAuthoredBy ? 'common.yes' : 'common.no' })}
</span>
)}
</div>

View File

@@ -5,7 +5,7 @@
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Check, Eye, EyeOff } from 'lucide-react';
import { Check, Eye, EyeOff, X, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -59,12 +59,21 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
const [providerId, setProviderId] = useState('');
const [model, setModel] = useState('sonnet');
const [includeCoAuthoredBy, setIncludeCoAuthoredBy] = useState(false);
const [settingsFile, setSettingsFile] = useState('');
// Direct mode state
const [authToken, setAuthToken] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [showToken, setShowToken] = useState(false);
// Available models state
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [modelInput, setModelInput] = useState('');
// Tags state
const [tags, setTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -76,6 +85,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
setEnabled(cliSettings.enabled);
setModel(cliSettings.settings.model || 'sonnet');
setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false);
setSettingsFile(cliSettings.settings.settingsFile || '');
setAvailableModels(cliSettings.settings.availableModels || []);
setTags(cliSettings.settings.tags || []);
// Determine mode based on settings
const hasCustomBaseUrl = Boolean(
@@ -104,8 +116,13 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
setProviderId('');
setModel('sonnet');
setIncludeCoAuthoredBy(false);
setSettingsFile('');
setAuthToken('');
setBaseUrl('');
setAvailableModels([]);
setModelInput('');
setTags([]);
setTagInput('');
setErrors({});
}
}, [cliSettings, open, providers]);
@@ -183,6 +200,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
env,
model,
includeCoAuthoredBy,
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
},
};
@@ -198,6 +218,37 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
}
};
// Handle add model
const handleAddModel = () => {
const newModel = modelInput.trim();
if (newModel && !availableModels.includes(newModel)) {
setAvailableModels([...availableModels, newModel]);
setModelInput('');
}
};
// Handle remove model
const handleRemoveModel = (modelToRemove: string) => {
setAvailableModels(availableModels.filter((m) => m !== modelToRemove));
};
// Handle add tag
const handleAddTag = () => {
const newTag = tagInput.trim();
if (newTag && !tags.includes(newTag)) {
setTags([...tags, newTag]);
setTagInput('');
}
};
// Handle remove tag
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((t) => t !== tagToRemove));
};
// Predefined tags
const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing'];
// Get selected provider info
const selectedProvider = providers.find((p) => p.id === providerId);
@@ -387,15 +438,154 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
</Tabs>
{/* Additional Settings (both modes) */}
<div className="flex items-center gap-2">
<Switch
id="coAuthored"
checked={includeCoAuthoredBy}
onCheckedChange={setIncludeCoAuthoredBy}
/>
<Label htmlFor="coAuthored" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
</Label>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Switch
id="coAuthored"
checked={includeCoAuthoredBy}
onCheckedChange={setIncludeCoAuthoredBy}
/>
<Label htmlFor="coAuthored" className="cursor-pointer">
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="settingsFile">
{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}
</Label>
<Input
id="settingsFile"
value={settingsFile}
onChange={(e) => setSettingsFile(e.target.value)}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
</p>
</div>
{/* Available Models Section */}
<div className="space-y-2">
<Label htmlFor="availableModels">
{formatMessage({ id: 'apiSettings.cliSettings.availableModels' })}
</Label>
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
{availableModels.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
>
{model}
<button
type="button"
onClick={() => handleRemoveModel(model)}
className="hover:text-destructive transition-colors"
>
×
</button>
</span>
))}
<div className="flex gap-2 flex-1">
<Input
id="availableModels"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddModel();
}
}}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.availableModelsPlaceholder' })}
className="flex-1 min-w-[120px]"
/>
<Button
type="button"
size="sm"
onClick={handleAddModel}
variant="outline"
>
<Check className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.availableModelsHint' })}
</p>
</div>
{/* Tags Section */}
<div className="space-y-2">
<Label htmlFor="tags">
{formatMessage({ id: 'apiSettings.cliSettings.tags' })}
</Label>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.tagsDescription' })}
</p>
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-destructive transition-colors"
aria-label={formatMessage({ id: 'apiSettings.cliSettings.removeTag' })}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<div className="flex gap-2 flex-1">
<Input
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
placeholder={formatMessage({ id: 'apiSettings.cliSettings.tagInputPlaceholder' })}
className="flex-1 min-w-[120px]"
/>
<Button
type="button"
size="sm"
onClick={handleAddTag}
variant="outline"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Predefined Tags */}
<div className="flex flex-wrap gap-1">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'apiSettings.cliSettings.predefinedTags' })}:
</span>
{predefinedTags.map((predefinedTag) => (
<button
key={predefinedTag}
type="button"
onClick={() => {
if (!tags.includes(predefinedTag)) {
setTags([...tags, predefinedTag]);
}
}}
disabled={tags.includes(predefinedTag)}
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{predefinedTag}
</button>
))}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { CoordinatorInputModal } from './CoordinatorInputModal';
// Mock zustand stores
vi.mock('@/stores/coordinatorStore', () => ({
useCoordinatorStore: () => ({
startCoordinator: vi.fn(),
}),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
// Mock fetch
global.fetch = vi.fn();
const mockMessages = {
'coordinator.modal.title': 'Start Coordinator',
'coordinator.modal.description': 'Describe the task',
'coordinator.form.taskDescription': 'Task Description',
'coordinator.form.taskDescriptionPlaceholder': 'Enter task description',
'coordinator.form.parameters': 'Parameters',
'coordinator.form.parametersPlaceholder': '{"key": "value"}',
'coordinator.form.parametersHelp': 'Optional JSON parameters',
'coordinator.form.characterCount': '{current} / {max} characters (min: {min})',
'coordinator.form.start': 'Start',
'coordinator.form.starting': 'Starting...',
'coordinator.validation.taskDescriptionRequired': 'Task description is required',
'coordinator.validation.taskDescriptionTooShort': 'Too short',
'coordinator.validation.taskDescriptionTooLong': 'Too long',
'coordinator.validation.parametersInvalidJson': 'Invalid JSON',
'common.actions.cancel': 'Cancel',
};
const renderWithIntl = (ui: React.ReactElement) => {
return render(
<IntlProvider locale="en" messages={mockMessages}>
{ui}
</IntlProvider>
);
};
describe('CoordinatorInputModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render when open', () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
expect(screen.getByText('Start Coordinator')).toBeInTheDocument();
expect(screen.getByText('Describe the task')).toBeInTheDocument();
});
it('should not render when closed', () => {
renderWithIntl(<CoordinatorInputModal open={false} onClose={mockOnClose} />);
expect(screen.queryByText('Start Coordinator')).not.toBeInTheDocument();
});
it('should show validation error for empty task description', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Task description is required')).toBeInTheDocument();
});
});
it('should show validation error for short task description', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Short' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Too short')).toBeInTheDocument();
});
});
it('should show validation error for invalid JSON parameters', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Valid task description here' } });
const paramsInput = screen.getByPlaceholderText('{"key": "value"}');
fireEvent.change(paramsInput, { target: { value: 'invalid json' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
});
});
it('should submit with valid task description', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
global.fetch = mockFetch;
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Valid task description with more than 10 characters' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/api/coordinator/start',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
});
});
});

View File

@@ -0,0 +1,249 @@
// ========================================
// Coordinator Input Modal Component
// ========================================
// Modal dialog for starting coordinator execution with task description and parameters
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Textarea';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { useNotifications } from '@/hooks/useNotifications';
// ========== Types ==========
export interface CoordinatorInputModalProps {
open: boolean;
onClose: () => void;
}
interface FormErrors {
taskDescription?: string;
parameters?: string;
}
// ========== Validation Helper ==========
function validateForm(taskDescription: string, parameters: string): FormErrors {
const errors: FormErrors = {};
// Validate task description
if (!taskDescription.trim()) {
errors.taskDescription = 'coordinator.validation.taskDescriptionRequired';
} else {
const length = taskDescription.trim().length;
if (length < 10) {
errors.taskDescription = 'coordinator.validation.taskDescriptionTooShort';
} else if (length > 2000) {
errors.taskDescription = 'coordinator.validation.taskDescriptionTooLong';
}
}
// Validate parameters if provided
if (parameters.trim()) {
try {
JSON.parse(parameters.trim());
} catch (error) {
errors.parameters = 'coordinator.validation.parametersInvalidJson';
}
}
return errors;
}
// ========== Component ==========
export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const { startCoordinator } = useCoordinatorStore();
// Form state
const [taskDescription, setTaskDescription] = useState('');
const [parameters, setParameters] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form when modal opens/closes
useEffect(() => {
if (open) {
setTaskDescription('');
setParameters('');
setErrors({});
}
}, [open]);
// Handle field change
const handleFieldChange = (
field: 'taskDescription' | 'parameters',
value: string
) => {
if (field === 'taskDescription') {
setTaskDescription(value);
} else {
setParameters(value);
}
// Clear error for this field when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
// Handle submit
const handleSubmit = async () => {
// Validate form
const validationErrors = validateForm(taskDescription, parameters);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
try {
// Parse parameters if provided
const parsedParams = parameters.trim() ? JSON.parse(parameters.trim()) : undefined;
// Generate execution ID
const executionId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Call API to start coordinator
const response = await fetch('/api/coordinator/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
executionId,
taskDescription: taskDescription.trim(),
parameters: parsedParams,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(error.message || 'Failed to start coordinator');
}
// Call store to update state
await startCoordinator(executionId, taskDescription.trim(), parsedParams);
success(formatMessage({ id: 'coordinator.success.started' }));
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
showError('Error', errorMessage);
console.error('Failed to start coordinator:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'coordinator.modal.title' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'coordinator.modal.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Task Description */}
<div className="space-y-2">
<Label htmlFor="task-description" className="text-base font-medium">
{formatMessage({ id: 'coordinator.form.taskDescription' })}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="task-description"
value={taskDescription}
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
rows={6}
className={errors.taskDescription ? 'border-destructive' : ''}
disabled={isSubmitting}
/>
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>
{formatMessage({ id: 'coordinator.form.characterCount' }, {
current: taskDescription.length,
min: 10,
max: 2000,
})}
</span>
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
<span className="text-green-600">Valid</span>
)}
</div>
{errors.taskDescription && (
<p className="text-sm text-destructive">
{formatMessage({ id: errors.taskDescription })}
</p>
)}
</div>
{/* Parameters (Optional) */}
<div className="space-y-2">
<Label htmlFor="parameters" className="text-base font-medium">
{formatMessage({ id: 'coordinator.form.parameters' })}
</Label>
<Input
id="parameters"
value={parameters}
onChange={(e) => handleFieldChange('parameters', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
className={`font-mono text-sm ${errors.parameters ? 'border-destructive' : ''}`}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
</p>
{errors.parameters && (
<p className="text-sm text-destructive">
{formatMessage({ id: errors.parameters })}
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={isSubmitting}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.form.starting' })}
</>
) : (
formatMessage({ id: 'coordinator.form.start' })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CoordinatorInputModal;

View File

@@ -0,0 +1,196 @@
// ========================================
// Coordinator Log Stream Component
// ========================================
// Real-time log display with level filtering and auto-scroll
import { useEffect, useRef, useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { FileText } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import { useCoordinatorStore, type LogLevel, type CoordinatorLog } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface CoordinatorLogStreamProps {
maxHeight?: number;
autoScroll?: boolean;
showFilter?: boolean;
}
type LogLevelFilter = LogLevel | 'all';
// ========== Component ==========
export function CoordinatorLogStream({
maxHeight = 400,
autoScroll = true,
showFilter = true,
}: CoordinatorLogStreamProps) {
const { formatMessage } = useIntl();
const { logs } = useCoordinatorStore();
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>('all');
const logContainerRef = useRef<HTMLPreElement>(null);
// Filter logs by level
const filteredLogs = useMemo(() => {
if (levelFilter === 'all') {
return logs;
}
return logs.filter((log) => log.level === levelFilter);
}, [logs, levelFilter]);
// Auto-scroll to latest log
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
// Get log level color
const getLogLevelColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'text-red-600';
case 'warn':
return 'text-yellow-600';
case 'success':
return 'text-green-600';
case 'debug':
return 'text-blue-600';
case 'info':
default:
return 'text-gray-600';
}
};
// Get log level background color
const getLogLevelBgColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'bg-red-50';
case 'warn':
return 'bg-yellow-50';
case 'success':
return 'bg-green-50';
case 'debug':
return 'bg-blue-50';
case 'info':
default:
return 'bg-gray-50';
}
};
// Format log entry
const formatLogEntry = (log: CoordinatorLog): string => {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const levelLabel = `[${log.level.toUpperCase()}]`;
const source = log.source ? `[${log.source}]` : '';
return `${timestamp} ${levelLabel} ${source} ${log.message}`;
};
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<CardTitle className="text-base">
{formatMessage({ id: 'coordinator.logs' })}
</CardTitle>
<span className="text-xs text-muted-foreground">
({filteredLogs.length} {formatMessage({ id: 'coordinator.entries' })})
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Level Filter */}
{showFilter && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{formatMessage({ id: 'coordinator.logLevel' })}
</Label>
<RadioGroup
value={levelFilter}
onValueChange={(value) => setLevelFilter(value as LogLevelFilter)}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="level-all" />
<Label htmlFor="level-all" className="cursor-pointer">
{formatMessage({ id: 'coordinator.level.all' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="info" id="level-info" />
<Label htmlFor="level-info" className="cursor-pointer text-gray-600">
{formatMessage({ id: 'coordinator.level.info' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="warn" id="level-warn" />
<Label htmlFor="level-warn" className="cursor-pointer text-yellow-600">
{formatMessage({ id: 'coordinator.level.warn' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="error" id="level-error" />
<Label htmlFor="level-error" className="cursor-pointer text-red-600">
{formatMessage({ id: 'coordinator.level.error' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="debug" id="level-debug" />
<Label htmlFor="level-debug" className="cursor-pointer text-blue-600">
{formatMessage({ id: 'coordinator.level.debug' })}
</Label>
</div>
</RadioGroup>
</div>
)}
{/* Log Display */}
<div className="space-y-2">
{filteredLogs.length === 0 ? (
<div className="flex items-center justify-center p-8 text-muted-foreground text-sm">
{formatMessage({ id: 'coordinator.noLogs' })}
</div>
) : (
<pre
ref={logContainerRef}
className={cn(
'w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto whitespace-pre-wrap break-words font-mono'
)}
style={{ maxHeight: `${maxHeight}px` }}
>
{filteredLogs.map((log) => (
<div
key={log.id}
className={cn(
'py-1 px-2 mb-1 rounded',
getLogLevelBgColor(log.level)
)}
>
<span className={getLogLevelColor(log.level)}>
{formatLogEntry(log)}
</span>
</div>
))}
</pre>
)}
</div>
</CardContent>
</Card>
);
}
export default CoordinatorLogStream;

View File

@@ -0,0 +1,289 @@
// ========================================
// Coordinator Question Modal Component
// ========================================
// Interactive question dialog for coordinator execution
import { useState, useEffect, useRef } from 'react';
import { useIntl } from 'react-intl';
import { Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { useCoordinatorStore, type CoordinatorQuestion } from '@/stores/coordinatorStore';
// ========== Types ==========
export interface CoordinatorQuestionModalProps {
question: CoordinatorQuestion | null;
onSubmit?: (questionId: string, answer: string | string[]) => void;
}
// ========== Component ==========
export function CoordinatorQuestionModal({
question,
onSubmit,
}: CoordinatorQuestionModalProps) {
const { formatMessage } = useIntl();
const { submitAnswer } = useCoordinatorStore();
const [answer, setAnswer] = useState<string | string[]>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Reset state when question changes
useEffect(() => {
if (question) {
setAnswer(question.type === 'multi' ? [] : '');
setError(null);
// Auto-focus on input when modal opens
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [question]);
// Validate answer
const validateAnswer = (): boolean => {
if (!question) return false;
if (question.required) {
if (question.type === 'multi') {
if (!Array.isArray(answer) || answer.length === 0) {
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
return false;
}
} else {
if (!answer || (typeof answer === 'string' && !answer.trim())) {
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
return false;
}
}
}
return true;
};
// Handle submit
const handleSubmit = async () => {
if (!question) return;
if (!validateAnswer()) return;
setIsSubmitting(true);
try {
const finalAnswer = typeof answer === 'string' ? answer.trim() : answer;
// Call store action
await submitAnswer(question.id, finalAnswer);
// Call optional callback
onSubmit?.(question.id, finalAnswer);
setError(null);
} catch (error) {
console.error('Failed to submit answer:', error);
setError(
error instanceof Error
? error.message
: formatMessage({ id: 'coordinator.error.submitFailed' })
);
} finally {
setIsSubmitting(false);
}
};
// Handle Enter key
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && question?.type === 'text') {
e.preventDefault();
handleSubmit();
}
};
// Handle multi-select change
const handleMultiSelectChange = (option: string, checked: boolean) => {
if (!Array.isArray(answer)) {
setAnswer([]);
return;
}
if (checked) {
setAnswer([...answer, option]);
} else {
setAnswer(answer.filter((a) => a !== option));
}
setError(null);
};
if (!question) {
return null;
}
return (
<Dialog open={!!question} onOpenChange={() => {/* Prevent manual close */}}>
<DialogContent
className="sm:max-w-[500px]"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{question.title}</DialogTitle>
{question.description && (
<DialogDescription>{question.description}</DialogDescription>
)}
</DialogHeader>
<div className="space-y-4 py-4">
{/* Text Input */}
{question.type === 'text' && (
<div className="space-y-2">
<Label htmlFor="text-answer">
{formatMessage({ id: 'coordinator.question.answer' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<Input
id="text-answer"
ref={inputRef}
value={typeof answer === 'string' ? answer : ''}
onChange={(e) => {
setAnswer(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'coordinator.question.textPlaceholder' })}
disabled={isSubmitting}
/>
</div>
)}
{/* Single Select (RadioGroup) */}
{question.type === 'single' && question.options && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.selectOne' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<RadioGroup
value={typeof answer === 'string' ? answer : ''}
onValueChange={(value) => {
setAnswer(value);
setError(null);
}}
disabled={isSubmitting}
>
{question.options.map((option) => (
<div key={option} className="flex items-center space-x-2">
<RadioGroupItem value={option} id={`option-${option}`} />
<Label htmlFor={`option-${option}`} className="cursor-pointer">
{option}
</Label>
</div>
))}
</RadioGroup>
</div>
)}
{/* Multi Select (Checkboxes) */}
{question.type === 'multi' && question.options && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.selectMultiple' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<div className="space-y-2">
{question.options.map((option) => {
const isChecked = Array.isArray(answer) && answer.includes(option);
return (
<div key={option} className="flex items-center space-x-2">
<input
type="checkbox"
id={`multi-${option}`}
checked={isChecked}
onChange={(e) => handleMultiSelectChange(option, e.target.checked)}
disabled={isSubmitting}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Label htmlFor={`multi-${option}`} className="cursor-pointer">
{option}
</Label>
</div>
);
})}
</div>
</div>
)}
{/* Yes/No Buttons */}
{question.type === 'yes_no' && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.confirm' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<div className="flex gap-2">
<Button
variant={answer === 'yes' ? 'default' : 'outline'}
onClick={() => {
setAnswer('yes');
setError(null);
}}
disabled={isSubmitting}
className="flex-1"
>
{formatMessage({ id: 'coordinator.question.yes' })}
</Button>
<Button
variant={answer === 'no' ? 'default' : 'outline'}
onClick={() => {
setAnswer('no');
setError(null);
}}
disabled={isSubmitting}
className="flex-1"
>
{formatMessage({ id: 'coordinator.question.no' })}
</Button>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.question.submitting' })}
</>
) : (
formatMessage({ id: 'coordinator.question.submit' })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CoordinatorQuestionModal;

View File

@@ -0,0 +1,116 @@
// ========================================
// CoordinatorTimeline Component
// ========================================
// Main horizontal timeline container for coordinator pipeline visualization
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useCoordinatorStore, selectCommandChain, selectCurrentNode } from '@/stores/coordinatorStore';
import { TimelineNode } from './TimelineNode';
import { NodeConnector } from './NodeConnector';
export interface CoordinatorTimelineProps {
className?: string;
autoScroll?: boolean;
onNodeClick?: (nodeId: string) => void;
}
/**
* Horizontal scrolling timeline displaying the coordinator command chain
* with connectors between nodes
*/
export function CoordinatorTimeline({
className,
autoScroll = true,
onNodeClick,
}: CoordinatorTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Store selectors
const commandChain = useCoordinatorStore(selectCommandChain);
const currentNode = useCoordinatorStore(selectCurrentNode);
// Auto-scroll to the current/latest node
useEffect(() => {
if (!autoScroll || !scrollContainerRef.current) return;
// Find the active or latest node
const activeNodeIndex = commandChain.findIndex(
(node) => node.status === 'running' || node.id === currentNode?.id
);
// If no active node, scroll to the end
if (activeNodeIndex === -1) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
behavior: 'smooth',
});
return;
}
// Scroll the active node into view
const nodeElements = scrollContainerRef.current.querySelectorAll('[data-node-id]');
const activeElement = nodeElements[activeNodeIndex] as HTMLElement;
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
}, [commandChain, currentNode?.id, autoScroll]);
// Handle node click
const handleNodeClick = (nodeId: string) => {
if (onNodeClick) {
onNodeClick(nodeId);
}
};
// Render empty state
if (commandChain.length === 0) {
return (
<div className={cn('flex items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-center">
<p className="text-sm">No pipeline nodes to display</p>
<p className="text-xs mt-1">Start a coordinator execution to see the pipeline</p>
</div>
</div>
);
}
return (
<div
ref={scrollContainerRef}
className={cn(
'flex items-center gap-0 p-4 overflow-x-auto overflow-y-hidden',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent',
className
)}
role="region"
aria-label="Coordinator pipeline timeline"
>
{commandChain.map((node, index) => (
<div key={node.id} className="flex items-center" data-node-id={node.id}>
{/* Timeline node */}
<TimelineNode
node={node}
isActive={currentNode?.id === node.id}
onClick={() => handleNodeClick(node.id)}
/>
{/* Connector to next node (if not last) */}
{index < commandChain.length - 1 && (
<NodeConnector
status={commandChain[index + 1].status}
className="mx-2"
/>
)}
</div>
))}
</div>
);
}
export default CoordinatorTimeline;

View File

@@ -0,0 +1,49 @@
// ========================================
// NodeConnector Component
// ========================================
// Visual connector line between pipeline nodes with status-based styling
import { cn } from '@/lib/utils';
import type { NodeExecutionStatus } from '@/stores/coordinatorStore';
export interface NodeConnectorProps {
status: NodeExecutionStatus;
className?: string;
}
/**
* Connector line between timeline nodes
* Changes color based on the status of the connected node
*/
export function NodeConnector({ status, className }: NodeConnectorProps) {
// Determine connector color and animation based on status
const getConnectorStyle = () => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-green-500 to-green-400';
case 'failed':
return 'bg-gradient-to-r from-red-500 to-red-400';
case 'running':
return 'bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse';
case 'pending':
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
case 'skipped':
return 'bg-gradient-to-r from-yellow-400 to-yellow-300';
default:
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
}
};
return (
<div
className={cn(
'w-16 h-1 shrink-0 transition-all duration-300',
getConnectorStyle(),
className
)}
aria-hidden="true"
/>
);
}
export default NodeConnector;

View File

@@ -0,0 +1,254 @@
// ========================================
// Node Details Panel Component
// ========================================
// Expandable panel showing node logs, error information, and retry/skip actions
import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { Loader2, RotateCcw, SkipForward, ChevronDown, ChevronUp } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useCoordinatorStore, type CommandNode } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface NodeDetailsPanelProps {
node: CommandNode;
isExpanded?: boolean;
onToggle?: (expanded: boolean) => void;
}
// ========== Component ==========
export function NodeDetailsPanel({
node,
isExpanded = true,
onToggle,
}: NodeDetailsPanelProps) {
const { formatMessage } = useIntl();
const { retryNode, skipNode, logs } = useCoordinatorStore();
const [expanded, setExpanded] = useState(isExpanded);
const [isLoading, setIsLoading] = useState(false);
const logScrollRef = useRef<HTMLPreElement>(null);
// Filter logs for this node
const nodeLogs = logs.filter((log) => log.nodeId === node.id);
// Auto-scroll to latest log
useEffect(() => {
if (expanded && logScrollRef.current) {
logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
}
}, [expanded, nodeLogs]);
// Handle expand/collapse
const handleToggle = () => {
const newExpanded = !expanded;
setExpanded(newExpanded);
onToggle?.(newExpanded);
};
// Handle retry
const handleRetry = async () => {
setIsLoading(true);
try {
await retryNode(node.id);
} catch (error) {
console.error('Failed to retry node:', error);
} finally {
setIsLoading(false);
}
};
// Handle skip
const handleSkip = async () => {
setIsLoading(true);
try {
await skipNode(node.id);
} catch (error) {
console.error('Failed to skip node:', error);
} finally {
setIsLoading(false);
}
};
// Get status color
const getStatusColor = (status: string): string => {
switch (status) {
case 'completed':
return 'text-green-600';
case 'failed':
return 'text-red-600';
case 'running':
return 'text-blue-600';
case 'skipped':
return 'text-yellow-600';
default:
return 'text-gray-600';
}
};
// Get status label
const getStatusLabel = (status: string): string => {
const labels: Record<string, string> = {
pending: 'coordinator.status.pending',
running: 'coordinator.status.running',
completed: 'coordinator.status.completed',
failed: 'coordinator.status.failed',
skipped: 'coordinator.status.skipped',
};
return labels[status] || status;
};
return (
<Card className="w-full">
<CardHeader className="cursor-pointer" onClick={handleToggle}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<button
className="inline-flex items-center justify-center"
onClick={handleToggle}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
<CardTitle className="text-lg">
{node.name}
</CardTitle>
<span className={cn('text-sm font-medium', getStatusColor(node.status))}>
{formatMessage({ id: getStatusLabel(node.status) })}
</span>
</div>
</div>
{node.description && (
<p className="text-sm text-muted-foreground mt-2">{node.description}</p>
)}
</CardHeader>
{expanded && (
<CardContent className="space-y-4">
{/* Logs Section */}
{nodeLogs.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">
{formatMessage({ id: 'coordinator.logs' })}
</h4>
<pre
ref={logScrollRef}
className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[200px] whitespace-pre-wrap break-words font-mono"
>
{nodeLogs
.map((log) => {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const levelLabel = `[${log.level.toUpperCase()}]`;
return `${timestamp} ${levelLabel} ${log.message}`;
})
.join('\n')}
</pre>
</div>
)}
{/* Error Information */}
{node.error && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-red-600">
{formatMessage({ id: 'coordinator.error' })}
</h4>
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
{node.error}
</div>
</div>
)}
{/* Output Section */}
{node.output && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">
{formatMessage({ id: 'coordinator.output' })}
</h4>
<pre className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[150px] whitespace-pre-wrap break-words font-mono">
{node.output}
</pre>
</div>
)}
{/* Node Information */}
<div className="grid grid-cols-2 gap-2 text-xs">
{node.startedAt && (
<div>
<span className="font-semibold text-muted-foreground">
{formatMessage({ id: 'coordinator.startedAt' })}:
</span>
<p>{new Date(node.startedAt).toLocaleString()}</p>
</div>
)}
{node.completedAt && (
<div>
<span className="font-semibold text-muted-foreground">
{formatMessage({ id: 'coordinator.completedAt' })}:
</span>
<p>{new Date(node.completedAt).toLocaleString()}</p>
</div>
)}
</div>
{/* Action Buttons */}
{node.status === 'failed' && (
<div className="flex gap-2 pt-4 border-t">
<Button
size="sm"
variant="outline"
onClick={handleRetry}
disabled={isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.retrying' })}
</>
) : (
<>
<RotateCcw className="w-3 h-3 mr-2" />
{formatMessage({ id: 'coordinator.retry' })}
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSkip}
disabled={isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.skipping' })}
</>
) : (
<>
<SkipForward className="w-3 h-3 mr-2" />
{formatMessage({ id: 'coordinator.skip' })}
</>
)}
</Button>
</div>
)}
</CardContent>
)}
</Card>
);
}
export default NodeDetailsPanel;

View File

@@ -0,0 +1,279 @@
# Coordinator Components
## CoordinatorInputModal
Modal dialog for starting coordinator execution with task description and optional JSON parameters.
### Usage
```tsx
import { CoordinatorInputModal } from '@/components/coordinator';
import { useState } from 'react';
function MyComponent() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Button onClick={() => setIsModalOpen(true)}>
Start Coordinator
</Button>
<CoordinatorInputModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
}
```
### Features
- **Task Description**: Required text area (10-2000 characters)
- **Parameters**: Optional JSON input
- **Validation**: Real-time validation for description length and JSON format
- **Loading State**: Displays loading indicator during submission
- **Error Handling**: Shows appropriate error messages
- **Internationalization**: Full i18n support (English/Chinese)
- **Notifications**: Success/error toasts via useNotifications hook
### API Integration
The component integrates with:
- **POST /api/coordinator/start**: Starts coordinator execution
- **coordinatorStore**: Updates Zustand store state
- **notificationStore**: Shows success/error notifications
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `open` | `boolean` | Yes | Controls modal visibility |
| `onClose` | `() => void` | Yes | Callback when modal closes |
### Validation Rules
- **Task Description**:
- Minimum length: 10 characters
- Maximum length: 2000 characters
- Required field
- **Parameters**:
- Optional field
- Must be valid JSON if provided
### Example Payload
```json
{
"executionId": "exec-1738477200000-abc123def",
"taskDescription": "Implement user authentication with JWT tokens",
"parameters": {
"timeout": 3600,
"priority": "high"
}
}
```
---
## Pipeline Timeline View Components
Horizontal scrolling timeline visualization for coordinator command pipeline execution.
### CoordinatorTimeline
Main timeline container that displays the command chain with auto-scrolling to active nodes.
#### Usage
```tsx
import { CoordinatorTimeline } from '@/components/coordinator';
function MyComponent() {
const handleNodeClick = (nodeId: string) => {
console.log('Node clicked:', nodeId);
};
return (
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
/>
);
}
```
#### Props
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `className` | `string` | No | - | Additional CSS classes |
| `autoScroll` | `boolean` | No | `true` | Auto-scroll to active/latest node |
| `onNodeClick` | `(nodeId: string) => void` | No | - | Callback when node is clicked |
#### Features
- **Horizontal Scrolling**: Smooth horizontal scroll with mouse wheel
- **Auto-scroll**: Automatically scrolls to the active or latest node
- **Empty State**: Shows helpful message when no nodes are present
- **Store Integration**: Uses `useCoordinatorStore` for state management
---
### TimelineNode
Individual node card displaying node status, timing, and expandable details.
#### Usage
```tsx
import { TimelineNode } from '@/components/coordinator';
import type { CommandNode } from '@/stores/coordinatorStore';
function MyComponent({ node }: { node: CommandNode }) {
return (
<TimelineNode
node={node}
isActive={true}
onClick={() => console.log('Clicked:', node.id)}
/>
);
}
```
#### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `node` | `CommandNode` | Yes | Node data from coordinator store |
| `isActive` | `boolean` | No | Whether this node is currently active |
| `onClick` | `() => void` | No | Callback when node is clicked |
| `className` | `string` | No | Additional CSS classes |
#### Features
- **Status Indicators**:
- `completed`: Green checkmark icon
- `failed`: Red X icon
- `running`: Blue spinning loader
- `pending`: Gray clock icon
- `skipped`: Yellow X icon
- **Status Badges**:
- Success (green)
- Failed (red)
- Running (blue)
- Pending (gray outline)
- Skipped (yellow)
- **Expandable Details**:
- Error messages (red background)
- Output text (scrollable pre)
- Result JSON (formatted and scrollable)
- **Timing Information**:
- Start time
- Completion time
- Duration calculation
- **Animations**:
- Hover scale effect (scale-105)
- Smooth transitions (300ms)
- Active ring (ring-2 ring-primary)
---
### NodeConnector
Visual connector line between timeline nodes with status-based styling.
#### Usage
```tsx
import { NodeConnector } from '@/components/coordinator';
function MyComponent() {
return (
<div className="flex items-center">
<TimelineNode node={node1} />
<NodeConnector status="completed" />
<TimelineNode node={node2} />
</div>
);
}
```
#### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `status` | `NodeExecutionStatus` | Yes | Status of the connected node |
| `className` | `string` | No | Additional CSS classes |
#### Status Colors
| Status | Color | Animation |
|--------|-------|-----------|
| `completed` | Green gradient | None |
| `failed` | Red gradient | None |
| `running` | Blue gradient | Pulse animation |
| `pending` | Gray gradient | None |
| `skipped` | Yellow gradient | None |
---
## Complete Example
```tsx
import { useState } from 'react';
import {
CoordinatorInputModal,
CoordinatorTimeline,
} from '@/components/coordinator';
import { Button } from '@/components/ui/Button';
function CoordinatorDashboard() {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleNodeClick = (nodeId: string) => {
console.log('Node clicked:', nodeId);
// Show node details panel, etc.
};
return (
<div className="flex flex-col h-screen">
{/* Header */}
<header className="border-b border-border p-4">
<Button onClick={() => setIsModalOpen(true)}>
New Execution
</Button>
</header>
{/* Timeline */}
<div className="flex-1 overflow-hidden">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
/>
</div>
{/* Input Modal */}
<CoordinatorInputModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
}
```
## Design Principles
- **Responsive**: Works on mobile and desktop
- **Dark Mode**: Full dark mode support via Tailwind CSS
- **Accessibility**: Proper ARIA labels and keyboard navigation
- **Performance**: Smooth 60fps animations
- **Mobile-first**: Touch-friendly interactions

View File

@@ -0,0 +1,213 @@
// ========================================
// TimelineNode Component
// ========================================
// Individual node card in the coordinator pipeline timeline
import { useState } from 'react';
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { CommandNode } from '@/stores/coordinatorStore';
export interface TimelineNodeProps {
node: CommandNode;
isActive?: boolean;
onClick?: () => void;
className?: string;
}
/**
* Individual timeline node card with status indicator and expandable details
*/
export function TimelineNode({ node, isActive = false, onClick, className }: TimelineNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Get status icon
const getStatusIcon = () => {
const iconClassName = 'h-5 w-5 shrink-0';
switch (node.status) {
case 'completed':
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
case 'failed':
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
case 'running':
return <Loader2 className={cn(iconClassName, 'text-blue-500 animate-spin')} />;
case 'skipped':
return <XCircle className={cn(iconClassName, 'text-yellow-500')} />;
case 'pending':
default:
return <Clock className={cn(iconClassName, 'text-gray-400')} />;
}
};
// Get status badge variant
const getStatusBadge = () => {
switch (node.status) {
case 'completed':
return <Badge variant="success">Success</Badge>;
case 'failed':
return <Badge variant="destructive">Failed</Badge>;
case 'running':
return <Badge variant="info">Running</Badge>;
case 'skipped':
return <Badge variant="warning">Skipped</Badge>;
case 'pending':
default:
return <Badge variant="outline">Pending</Badge>;
}
};
// Format timestamp
const formatTime = (timestamp?: string) => {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleTimeString();
};
// Calculate duration
const getDuration = () => {
if (!node.startedAt || !node.completedAt) return null;
const start = new Date(node.startedAt).getTime();
const end = new Date(node.completedAt).getTime();
const durationMs = end - start;
if (durationMs < 1000) return `${durationMs}ms`;
const seconds = Math.floor(durationMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
const hasDetails = Boolean(node.output || node.error || node.result);
return (
<Card
className={cn(
'w-64 shrink-0 cursor-pointer transition-all duration-300',
'hover:shadow-lg hover:scale-105',
isActive && 'ring-2 ring-primary',
isExpanded && 'w-80',
className
)}
onClick={onClick}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-foreground truncate" title={node.name}>
{node.name}
</h4>
{node.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5" title={node.description}>
{node.description}
</p>
)}
</div>
</div>
<div className="shrink-0">
{getStatusBadge()}
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{/* Timing information */}
{(node.startedAt || node.completedAt) && (
<div className="text-xs text-muted-foreground space-y-0.5 mb-2">
{node.startedAt && (
<div>Started: {formatTime(node.startedAt)}</div>
)}
{node.completedAt && (
<div>Completed: {formatTime(node.completedAt)}</div>
)}
{getDuration() && (
<div className="font-medium">Duration: {getDuration()}</div>
)}
</div>
)}
{/* Expand/collapse toggle for details */}
{hasDetails && (
<>
<button
onClick={handleToggleExpand}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors w-full"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
Hide details
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Show details
</>
)}
</button>
{/* Expanded details panel */}
{isExpanded && (
<div className="mt-2 space-y-2">
{/* Error message */}
{node.error && (
<div className="p-2 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800">
<div className="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">
Error:
</div>
<div className="text-xs text-red-600 dark:text-red-300 break-words">
{node.error}
</div>
</div>
)}
{/* Output */}
{Boolean(node.output) && (
<div className="p-2 rounded-md bg-muted/50 border border-border">
<div className="text-xs font-semibold text-foreground mb-1">
Output:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{String(node.output)}
</pre>
</div>
)}
{/* Result */}
{Boolean(node.result) && (
<div className="p-2 rounded-md bg-muted/50 border border-border">
<div className="text-xs font-semibold text-foreground mb-1">
Result:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{typeof node.result === 'object' && node.result !== null
? JSON.stringify(node.result, null, 2)
: String(node.result)}
</pre>
</div>
)}
</div>
)}
</>
)}
{/* Command information */}
{node.command && !isExpanded && (
<div className="mt-2 text-xs text-muted-foreground truncate" title={node.command}>
<span className="font-mono">{node.command}</span>
</div>
)}
</CardContent>
</Card>
);
}
export default TimelineNode;

View File

@@ -0,0 +1,23 @@
// Coordinator components
export { CoordinatorInputModal } from './CoordinatorInputModal';
export type { CoordinatorInputModalProps } from './CoordinatorInputModal';
// Timeline visualization components
export { CoordinatorTimeline } from './CoordinatorTimeline';
export type { CoordinatorTimelineProps } from './CoordinatorTimeline';
export { TimelineNode } from './TimelineNode';
export type { TimelineNodeProps } from './TimelineNode';
export { NodeConnector } from './NodeConnector';
export type { NodeConnectorProps } from './NodeConnector';
// Node interaction components
export { NodeDetailsPanel } from './NodeDetailsPanel';
export type { NodeDetailsPanelProps } from './NodeDetailsPanel';
export { CoordinatorLogStream } from './CoordinatorLogStream';
export type { CoordinatorLogStreamProps } from './CoordinatorLogStream';
export { CoordinatorQuestionModal } from './CoordinatorQuestionModal';
export type { CoordinatorQuestionModalProps } from './CoordinatorQuestionModal';

View File

@@ -121,7 +121,7 @@ export function Header({
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
<span className="absolute -top-1 -right-1 min-h-4 min-w-4 px-1 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}

View File

@@ -1,10 +1,9 @@
// ========================================
// Sidebar Component
// ========================================
// Collapsible navigation sidebar with route links
// Collapsible navigation sidebar with 6-group accordion structure
import { useState, useCallback, useMemo } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Home,
@@ -12,8 +11,6 @@ import {
Workflow,
RefreshCw,
AlertCircle,
ListTodo,
Search,
Sparkles,
Terminal,
Brain,
@@ -28,9 +25,15 @@ import {
Shield,
History,
Server,
Layers,
Wrench,
Cog,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Accordion } from '@/components/ui/Accordion';
import { NavGroup, type NavItem } from '@/components/shared/NavGroup';
import { useAppStore } from '@/stores/appStore';
export interface SidebarProps {
/** Whether sidebar is collapsed */
@@ -43,34 +46,82 @@ export interface SidebarProps {
onMobileClose?: () => void;
}
interface NavItem {
path: string;
label: string;
icon: React.ElementType;
badge?: number | string;
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
// Navigation group definitions
interface NavGroupDef {
id: string;
titleKey: string;
icon?: React.ElementType;
items: Array<{
path: string;
labelKey: string;
icon: React.ElementType;
badge?: number | string;
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
}>;
}
// Navigation item definitions (without labels for i18n)
const navItemDefinitions: Omit<NavItem, 'label'>[] = [
{ path: '/', icon: Home },
{ path: '/sessions', icon: FolderKanban },
{ path: '/lite-tasks', icon: Zap },
{ path: '/project', icon: LayoutDashboard },
{ path: '/history', icon: Clock },
{ path: '/orchestrator', icon: Workflow },
{ path: '/loops', icon: RefreshCw },
{ path: '/issues', icon: AlertCircle },
{ path: '/skills', icon: Sparkles },
{ path: '/commands', icon: Terminal },
{ path: '/memory', icon: Brain },
{ path: '/prompts', icon: History },
{ path: '/hooks', icon: GitFork },
{ path: '/settings', icon: Settings },
{ path: '/settings/rules', icon: Shield },
{ path: '/settings/codexlens', icon: Sparkles },
{ path: '/api-settings', icon: Server },
{ path: '/help', icon: HelpCircle },
// Define the 6 navigation groups with their items
const navGroupDefinitions: NavGroupDef[] = [
{
id: 'overview',
titleKey: 'navigation.groups.overview',
icon: Layers,
items: [
{ path: '/', labelKey: 'navigation.main.home', icon: Home },
{ path: '/project', labelKey: 'navigation.main.project', icon: LayoutDashboard },
],
},
{
id: 'workflow',
titleKey: 'navigation.groups.workflow',
icon: Workflow,
items: [
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
{ path: '/loops', labelKey: 'navigation.main.loops', icon: RefreshCw },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
],
},
{
id: 'knowledge',
titleKey: 'navigation.groups.knowledge',
icon: Brain,
items: [
{ path: '/memory', labelKey: 'navigation.main.memory', icon: Brain },
{ path: '/prompts', labelKey: 'navigation.main.prompts', icon: History },
{ path: '/skills', labelKey: 'navigation.main.skills', icon: Sparkles },
{ path: '/commands', labelKey: 'navigation.main.commands', icon: Terminal },
],
},
{
id: 'issues',
titleKey: 'navigation.groups.issues',
icon: AlertCircle,
items: [
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
],
},
{
id: 'tools',
titleKey: 'navigation.groups.tools',
icon: Wrench,
items: [
{ path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork },
],
},
{
id: 'configuration',
titleKey: 'navigation.groups.configuration',
icon: Cog,
items: [
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
{ path: '/help', labelKey: 'navigation.main.help', icon: HelpCircle },
],
},
];
export function Sidebar({
@@ -80,18 +131,17 @@ export function Sidebar({
onMobileClose,
}: SidebarProps) {
const { formatMessage } = useIntl();
const location = useLocation();
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
const { sidebarCollapsed, expandedNavGroups, setExpandedNavGroups } = useAppStore();
const isCollapsed = onCollapsedChange ? collapsed : internalCollapsed;
const isCollapsed = onCollapsedChange ? collapsed : sidebarCollapsed;
const handleToggleCollapse = useCallback(() => {
if (onCollapsedChange) {
onCollapsedChange(!collapsed);
} else {
setInternalCollapsed(!internalCollapsed);
useAppStore.getState().setSidebarCollapsed(!sidebarCollapsed);
}
}, [collapsed, internalCollapsed, onCollapsedChange]);
}, [collapsed, sidebarCollapsed, onCollapsedChange]);
const handleNavClick = useCallback(() => {
// Close mobile sidebar when navigating
@@ -100,31 +150,18 @@ export function Sidebar({
}
}, [onMobileClose]);
// Build nav items with translated labels
const navItems = useMemo(() => {
const keyMap: Record<string, string> = {
'/': 'main.home',
'/sessions': 'main.sessions',
'/lite-tasks': 'main.liteTasks',
'/project': 'main.project',
'/history': 'main.history',
'/orchestrator': 'main.orchestrator',
'/loops': 'main.loops',
'/issues': 'main.issues',
'/skills': 'main.skills',
'/commands': 'main.commands',
'/memory': 'main.memory',
'/prompts': 'main.prompts',
'/hooks': 'main.hooks',
'/settings': 'main.settings',
'/settings/rules': 'main.rules',
'/settings/codexlens': 'main.codexlens',
'/api-settings': 'main.apiSettings',
'/help': 'main.help',
};
return navItemDefinitions.map((item) => ({
...item,
label: formatMessage({ id: `navigation.${keyMap[item.path]}` }),
const handleAccordionChange = useCallback((value: string[]) => {
setExpandedNavGroups(value);
}, [setExpandedNavGroups]);
// Build nav groups with translated labels
const navGroups = useMemo(() => {
return navGroupDefinitions.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
label: formatMessage({ id: item.labelKey }),
})) as NavItem[],
}));
}, [formatMessage]);
@@ -153,57 +190,42 @@ export function Sidebar({
aria-label={formatMessage({ id: 'navigation.header.brand' })}
>
<nav className="flex-1 py-3 overflow-y-auto">
<ul className="space-y-1 px-2">
{navItems.map((item) => {
const Icon = item.icon;
// Parse item path to extract base path and query params
const [basePath, searchParams] = item.path.split('?');
const isActive = location.pathname === basePath ||
(basePath !== '/' && location.pathname.startsWith(basePath));
// For query param items, also check if search matches
const isQueryParamActive = searchParams &&
location.search.includes(searchParams);
return (
<li key={item.path}>
<NavLink
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors',
'hover:bg-hover hover:text-foreground',
(isActive && !searchParams) || isQueryParamActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground',
isCollapsed && 'justify-center px-2'
)}
title={isCollapsed ? item.label : undefined}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge !== undefined && (
<span
className={cn(
'px-2 py-0.5 text-xs font-semibold rounded-full',
item.badgeVariant === 'success' && 'bg-success-light text-success',
item.badgeVariant === 'warning' && 'bg-warning-light text-warning',
item.badgeVariant === 'info' && 'bg-info-light text-info',
(!item.badgeVariant || item.badgeVariant === 'default') &&
'bg-muted text-muted-foreground'
)}
>
{item.badge}
</span>
)}
</>
)}
</NavLink>
</li>
);
})}
</ul>
{isCollapsed ? (
// Collapsed view: render flat list of icons
<div className="space-y-4 px-2">
{navGroups.map((group) => (
<NavGroup
key={group.id}
groupId={group.id}
titleKey={group.titleKey}
icon={group.icon}
items={group.items}
collapsed={true}
onNavClick={handleNavClick}
/>
))}
</div>
) : (
// Expanded view: render accordion groups
<Accordion
type="multiple"
value={expandedNavGroups}
onValueChange={handleAccordionChange}
className="space-y-1 px-2"
>
{navGroups.map((group) => (
<NavGroup
key={group.id}
groupId={group.id}
titleKey={group.titleKey}
icon={group.icon}
items={group.items}
collapsed={false}
onNavClick={handleNavClick}
/>
))}
</Accordion>
)}
</nav>
{/* Sidebar footer - collapse toggle */}

View File

@@ -481,11 +481,15 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
<div
className={cn(
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
'border-l-4',
'border-l-4 relative',
getTypeBorder(notification.type),
isRead && 'opacity-70'
)}
>
{/* Unread dot indicator */}
{!isRead && (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-destructive" />
)}
<div className="flex gap-3">
{/* Icon */}
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
@@ -512,6 +516,23 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
{notification.source}
</Badge>
)}
{/* Read/Unread status badge */}
{!isRead && (
<Badge
variant="destructive"
className="h-5 px-1.5 text-[10px] font-medium shrink-0"
>
{formatMessage({ id: 'notifications.unread' }) || '未读'}
</Badge>
)}
{isRead && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-medium shrink-0 opacity-60"
>
{formatMessage({ id: 'notifications.read' }) || '已读'}
</Badge>
)}
</div>
{/* Timestamp row: absolute + relative */}

View File

@@ -0,0 +1,91 @@
// ========================================
// BatchOperationToolbar Component
// ========================================
// Toolbar for batch operations on prompts
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { Trash2, X } from 'lucide-react';
export interface BatchOperationToolbarProps {
/** Number of selected items */
selectedCount: number;
/** Whether all items are selected */
allSelected: boolean;
/** Called when select all is toggled */
onSelectAll: (selected: boolean) => void;
/** Called when clear selection is triggered */
onClearSelection: () => void;
/** Called when batch delete is triggered */
onDelete: () => void;
/** Whether delete operation is in progress */
isDeleting?: boolean;
/** Optional className */
className?: string;
}
/**
* BatchOperationToolbar component for bulk actions
*/
export function BatchOperationToolbar({
selectedCount,
allSelected,
onSelectAll,
onClearSelection,
onDelete,
isDeleting = false,
className,
}: BatchOperationToolbarProps) {
const { formatMessage } = useIntl();
if (selectedCount === 0) {
return null;
}
return (
<div
className={cn(
'flex items-center justify-between gap-4 p-3 bg-primary/10 rounded-lg border border-primary/20',
className
)}
>
{/* Selection info and select all */}
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => onSelectAll(checked === true)}
aria-label={formatMessage({ id: 'prompts.batch.selectAll' })}
/>
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'prompts.batch.selected' }, { count: selectedCount })}
</span>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onClearSelection}
disabled={isDeleting}
>
<X className="h-4 w-4 mr-1" />
{formatMessage({ id: 'prompts.batch.clearSelection' })}
</Button>
<Button
variant="destructive"
size="sm"
onClick={onDelete}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4 mr-1" />
{formatMessage({ id: 'prompts.batch.deleteSelected' })}
</Button>
</div>
</div>
);
}
export default BatchOperationToolbar;

View File

@@ -0,0 +1,387 @@
// ========================================
// InsightDetailPanel Component
// ========================================
// Display detailed view of a single insight with patterns, suggestions, and metadata
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
X,
Sparkles,
Bot,
Code2,
Cpu,
Trash2,
AlertTriangle,
Lightbulb,
Clock,
FileText,
} from 'lucide-react';
import type { InsightHistory, Pattern, Suggestion } from '@/lib/api';
import { Button } from '@/components/ui/Button';
export interface InsightDetailPanelProps {
/** Insight to display (null = panel hidden) */
insight: InsightHistory | null;
/** Called when close button clicked */
onClose: () => void;
/** Called when delete button clicked */
onDelete?: (insightId: string) => void;
/** Is delete operation in progress */
isDeleting?: boolean;
/** Optional className */
className?: string;
}
// Tool icon mapping
const toolConfig = {
gemini: {
icon: Sparkles,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
label: 'Gemini',
},
qwen: {
icon: Bot,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
label: 'Qwen',
},
codex: {
icon: Code2,
color: 'text-green-500',
bgColor: 'bg-green-500/10',
label: 'Codex',
},
default: {
icon: Cpu,
color: 'text-gray-500',
bgColor: 'bg-gray-500/10',
label: 'CLI',
},
};
// Severity configuration
const severityConfig = {
error: {
badge: 'bg-red-500/10 text-red-500 border-red-500/20',
border: 'border-l-red-500',
dot: 'bg-red-500',
},
warning: {
badge: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20',
border: 'border-l-yellow-500',
dot: 'bg-yellow-500',
},
info: {
badge: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
border: 'border-l-blue-500',
dot: 'bg-blue-500',
},
default: {
badge: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
border: 'border-l-gray-500',
dot: 'bg-gray-500',
},
};
// Suggestion type configuration
const suggestionTypeConfig = {
refactor: {
badge: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
icon: 'refactor',
},
optimize: {
badge: 'bg-green-500/10 text-green-500 border-green-500/20',
icon: 'optimize',
},
fix: {
badge: 'bg-red-500/10 text-red-500 border-red-500/20',
icon: 'fix',
},
document: {
badge: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
icon: 'document',
},
};
/**
* Format timestamp to relative time
*/
function formatRelativeTime(timestamp: string, locale: string): string {
const date = new Date(timestamp);
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 (locale === 'zh') {
if (diffMins < 1) return '刚刚';
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
return `${diffDays}天前`;
}
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
/**
* PatternItem component for displaying a single pattern
*/
function PatternItem({ pattern, locale }: { pattern: Pattern; locale: string }) {
const { formatMessage } = useIntl();
const severity = pattern.severity ?? 'info';
const config = severityConfig[severity] ?? severityConfig.default;
return (
<div
className={cn(
'p-3 rounded-md border bg-card',
'border-l-4',
config.border
)}
>
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'px-2 py-0.5 text-xs font-medium rounded border',
config.badge
)}
>
{pattern.name?.split(' ')[0] || 'Pattern'}
</span>
<span
className={cn(
'px-2 py-0.5 text-xs font-medium rounded border uppercase',
config.badge
)}
>
{severity}
</span>
</div>
<p className="text-sm text-foreground mb-2">{pattern.description}</p>
{pattern.example && (
<div className="mt-2 p-2 bg-muted rounded text-xs font-mono overflow-x-auto">
<code className="text-muted-foreground">{pattern.example}</code>
</div>
)}
</div>
);
}
/**
* SuggestionItem component for displaying a single suggestion
*/
function SuggestionItem({ suggestion, locale }: { suggestion: Suggestion; locale: string }) {
const { formatMessage } = useIntl();
const config = suggestionTypeConfig[suggestion.type] ?? suggestionTypeConfig.refactor;
const typeLabel = formatMessage({ id: `prompts.suggestions.types.${suggestion.type}` });
return (
<div className="p-3 rounded-md border border-border bg-card">
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
'px-2 py-0.5 text-xs font-medium rounded border',
config.badge
)}
>
{typeLabel}
</span>
{suggestion.effort && (
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'prompts.suggestions.effort' })}: {suggestion.effort}
</span>
)}
</div>
<h4 className="text-sm font-medium text-foreground mb-1">{suggestion.title}</h4>
<p className="text-sm text-muted-foreground mb-2">{suggestion.description}</p>
{suggestion.code && (
<div className="mt-2 p-2 bg-muted rounded text-xs font-mono overflow-x-auto">
<code className="text-muted-foreground">{suggestion.code}</code>
</div>
)}
</div>
);
}
/**
* InsightDetailPanel component - Display full insight details
*/
export function InsightDetailPanel({
insight,
onClose,
onDelete,
isDeleting = false,
className,
}: InsightDetailPanelProps) {
const { formatMessage } = useIntl();
const locale = useIntl().locale;
// Don't render if no insight
if (!insight) {
return null;
}
const config = toolConfig[insight.tool as keyof typeof toolConfig] ?? toolConfig.default;
const ToolIcon = config.icon;
const timeAgo = formatRelativeTime(insight.created_at, locale);
const patternCount = insight.patterns?.length ?? 0;
const suggestionCount = insight.suggestions?.length ?? 0;
return (
<div
className={cn(
'fixed inset-y-0 right-0 w-full max-w-md bg-background border-l border-border shadow-lg',
'flex flex-col',
'animate-in slide-in-from-right',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<ToolIcon className={cn('h-5 w-5', config.color)} />
<h2 className="text-lg font-semibold text-card-foreground">
{formatMessage({ id: 'prompts.insightDetail.title' })}
</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-accent transition-colors"
aria-label={formatMessage({ id: 'common.actions.close' })}
>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Metadata */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className={cn('flex items-center gap-1.5', config.color)}>
<ToolIcon className="h-3.5 w-3.5" />
<span className="font-medium">{config.label}</span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{timeAgo}</span>
</div>
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5" />
<span>
{insight.prompt_count} {formatMessage({ id: 'prompts.insightDetail.promptsAnalyzed' })}
</span>
</div>
</div>
{/* Patterns */}
{insight.patterns && insight.patterns.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-card-foreground">
{formatMessage({ id: 'prompts.insightDetail.patterns' })} ({patternCount})
</h3>
</div>
<div className="space-y-2">
{insight.patterns.map((pattern) => (
<PatternItem key={pattern.id} pattern={pattern} locale={locale} />
))}
</div>
</div>
)}
{/* Suggestions */}
{insight.suggestions && insight.suggestions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-card-foreground">
{formatMessage({ id: 'prompts.insightDetail.suggestions' })} ({suggestionCount})
</h3>
</div>
<div className="space-y-2">
{insight.suggestions.map((suggestion) => (
<SuggestionItem key={suggestion.id} suggestion={suggestion} locale={locale} />
))}
</div>
</div>
)}
{/* Empty state */}
{(!insight.patterns || insight.patterns.length === 0) &&
(!insight.suggestions || insight.suggestions.length === 0) && (
<div className="text-center py-8 text-muted-foreground text-sm">
{formatMessage({ id: 'prompts.insightDetail.noContent' })}
</div>
)}
</div>
{/* Footer actions */}
{onDelete && (
<div className="p-4 border-t border-border">
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(insight.id)}
disabled={isDeleting}
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting
? formatMessage({ id: 'prompts.insightDetail.deleting' })
: formatMessage({ id: 'common.actions.delete' })
}
</Button>
</div>
)}
</div>
);
}
/**
* InsightDetailPanelOverlay - Full screen overlay with panel
*/
export interface InsightDetailPanelOverlayProps extends InsightDetailPanelProps {
/** Show overlay backdrop */
showOverlay?: boolean;
}
export function InsightDetailPanelOverlay({
insight,
onClose,
onDelete,
isDeleting = false,
showOverlay = true,
className,
}: InsightDetailPanelOverlayProps) {
if (!insight) {
return null;
}
return (
<>
{showOverlay && (
<div
className="fixed inset-0 bg-black/60 z-40 animate-in fade-in"
onClick={onClose}
/>
)}
<InsightDetailPanel
insight={insight}
onClose={onClose}
onDelete={onDelete}
isDeleting={isDeleting}
className={cn('z-50', className)}
/>
</>
);
}
export default InsightDetailPanel;

View File

@@ -0,0 +1,248 @@
// ========================================
// InsightsHistoryList Component
// ========================================
// Display past insight analysis results with tool icon, timestamp, pattern count, and suggestion count
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
Sparkles,
Bot,
Code2,
Cpu,
Brain,
Loader2,
} from 'lucide-react';
import type { InsightHistory } from '@/lib/api';
export interface InsightsHistoryListProps {
/** Array of historical insights */
insights?: InsightHistory[];
/** Loading state */
isLoading?: boolean;
/** Called when an insight card is clicked */
onInsightSelect?: (insightId: string) => void;
/** Optional className */
className?: string;
}
// Tool icon mapping
const toolConfig = {
gemini: {
icon: Sparkles,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
label: 'Gemini',
},
qwen: {
icon: Bot,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10',
label: 'Qwen',
},
codex: {
icon: Code2,
color: 'text-green-500',
bgColor: 'bg-green-500/10',
label: 'Codex',
},
default: {
icon: Cpu,
color: 'text-gray-500',
bgColor: 'bg-gray-500/10',
label: 'CLI',
},
};
/**
* Format timestamp to relative time (e.g., "2h ago")
*/
function formatTimeAgo(timestamp: string, locale: string): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
const isZh = locale === 'zh';
if (minutes < 1) return isZh ? '刚刚' : 'Just now';
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
return date.toLocaleDateString();
}
/**
* Get severity level from patterns
* Pattern severity: 'error' | 'warning' | 'info'
*/
function getSeverityLevel(patterns: InsightHistory['patterns']): 'low' | 'medium' | 'high' {
if (!patterns || patterns.length === 0) return 'low';
const hasHigh = patterns.some(p => p.severity === 'error');
const hasMedium = patterns.some(p => p.severity === 'warning');
return hasHigh ? 'high' : hasMedium ? 'medium' : 'low';
}
/**
* Severity color mapping
*/
const severityConfig = {
low: {
border: 'border-l-4 border-l-green-500',
},
medium: {
border: 'border-l-4 border-l-yellow-500',
},
high: {
border: 'border-l-4 border-l-red-500',
},
};
/**
* InsightHistoryCard component for displaying a single insight history entry
*/
function InsightHistoryCard({
insight,
locale,
onClick,
}: {
insight: InsightHistory;
locale: string;
onClick: () => void;
}) {
const { formatMessage } = useIntl();
const severity = getSeverityLevel(insight.patterns);
const config = toolConfig[insight.tool as keyof typeof toolConfig] ?? toolConfig.default;
const ToolIcon = config.icon;
const timeAgo = formatTimeAgo(insight.created_at, locale);
const patternCount = insight.patterns?.length ?? 0;
const suggestionCount = insight.suggestions?.length ?? 0;
return (
<div
onClick={onClick}
className={cn(
'p-3 rounded-lg border border-border bg-card hover:bg-muted/50 cursor-pointer transition-colors',
'border-l-4',
severityConfig[severity].border
)}
>
{/* Header: Tool and timestamp */}
<div className="flex items-center justify-between mb-2">
<div className={cn('flex items-center gap-1.5', config.color)}>
<ToolIcon className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{config.label}</span>
</div>
<div className="text-xs text-muted-foreground">{timeAgo}</div>
</div>
{/* Stats: Patterns, Suggestions, Prompts */}
<div className="flex items-center gap-3">
<div className="flex items-baseline gap-1">
<span className="text-sm font-semibold text-foreground">{patternCount}</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'prompts.insightsHistory.patterns' })}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-sm font-semibold text-foreground">{suggestionCount}</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'prompts.insightsHistory.suggestions' })}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-sm font-semibold text-foreground">{insight.prompt_count}</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'prompts.insightsHistory.prompts' })}
</span>
</div>
</div>
{/* Pattern preview (if available) */}
{insight.patterns && insight.patterns.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/50">
<div
className={cn(
'flex items-start gap-1.5 text-xs',
insight.patterns[0].severity === 'error'
? 'text-red-500'
: insight.patterns[0].severity === 'warning'
? 'text-yellow-600'
: 'text-blue-500'
)}
>
<span className="font-medium uppercase">
{insight.patterns[0].name?.split(' ')[0] || 'Pattern'}
</span>
<span className="text-muted-foreground truncate flex-1">
{insight.patterns[0].description?.slice(0, 50)}
{insight.patterns[0].description && insight.patterns[0].description.length > 50 ? '...' : ''}
</span>
</div>
</div>
)}
</div>
);
}
/**
* InsightsHistoryList component - Display past insight analysis results
*/
export function InsightsHistoryList({
insights = [],
isLoading = false,
onInsightSelect,
className,
}: InsightsHistoryListProps) {
const { formatMessage } = useIntl();
const locale = useIntl().locale;
// Loading state
if (isLoading) {
return (
<div className={cn('rounded-lg border border-border bg-card p-4', className)}>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground mr-2" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'prompts.insightsHistory.loading' })}
</span>
</div>
</div>
);
}
// Empty state
if (insights.length === 0) {
return (
<div className={cn('rounded-lg border border-border bg-card p-4', className)}>
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Brain className="h-10 w-10 text-muted-foreground/50 mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'prompts.insightsHistory.empty.title' })}
</h3>
<p className="text-xs text-muted-foreground max-w-xs">
{formatMessage({ id: 'prompts.insightsHistory.empty.message' })}
</p>
</div>
</div>
);
}
// List of insights
return (
<div className={cn('space-y-2', className)}>
{insights.map((insight) => (
<InsightHistoryCard
key={insight.id}
insight={insight}
locale={locale}
onClick={() => onInsightSelect?.(insight.id)}
/>
))}
</div>
);
}
export default InsightsHistoryList;

View File

@@ -0,0 +1,143 @@
// ========================================
// NavGroup Component
// ========================================
// Collapsible navigation group using Radix Accordion
import { NavLink, useLocation } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/Accordion';
export interface NavItem {
path: string;
label: string;
icon: React.ElementType;
badge?: number | string;
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
}
export interface NavGroupProps {
/** Unique identifier for the group */
groupId: string;
/** Title i18n key */
titleKey: string;
/** Optional icon for group header */
icon?: React.ElementType;
/** Navigation items in this group */
items: NavItem[];
/** Whether sidebar is collapsed */
collapsed?: boolean;
/** Callback when nav item is clicked */
onNavClick?: () => void;
}
export function NavGroup({
groupId,
titleKey,
icon: Icon,
items,
collapsed = false,
onNavClick,
}: NavGroupProps) {
const { formatMessage } = useIntl();
const location = useLocation();
const title = formatMessage({ id: titleKey });
// If collapsed, render items without accordion
if (collapsed) {
return (
<div className="space-y-1">
{items.map((item) => {
const ItemIcon = item.icon;
const [basePath] = item.path.split('?');
const isActive =
location.pathname === basePath ||
(basePath !== '/' && location.pathname.startsWith(basePath));
return (
<NavLink
key={item.path}
to={item.path}
onClick={onNavClick}
className={cn(
'flex items-center justify-center gap-3 px-2 py-2.5 rounded-md text-sm transition-colors',
'hover:bg-hover hover:text-foreground',
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground'
)}
title={item.label}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" />
</NavLink>
);
})}
</div>
);
}
return (
<AccordionItem value={groupId} className="border-none">
<AccordionTrigger className="px-3 py-2 hover:no-underline hover:bg-hover/50 rounded-md text-muted-foreground hover:text-foreground">
<div className="flex items-center gap-2 text-sm font-semibold">
{Icon && <Icon className="w-4 h-4" />}
<span>{title}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-1">
<ul className="space-y-1">
{items.map((item) => {
const ItemIcon = item.icon;
const [basePath, searchParams] = item.path.split('?');
const isActive =
location.pathname === basePath ||
(basePath !== '/' && location.pathname.startsWith(basePath));
const isQueryParamActive =
searchParams && location.search.includes(searchParams);
return (
<li key={item.path}>
<NavLink
to={item.path}
onClick={onNavClick}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors pl-6',
'hover:bg-hover hover:text-foreground',
(isActive && !searchParams) || isQueryParamActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground'
)}
>
<ItemIcon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">{item.label}</span>
{item.badge !== undefined && (
<span
className={cn(
'px-2 py-0.5 text-xs font-semibold rounded-full',
item.badgeVariant === 'success' &&
'bg-success-light text-success',
item.badgeVariant === 'warning' &&
'bg-warning-light text-warning',
item.badgeVariant === 'info' && 'bg-info-light text-info',
(!item.badgeVariant || item.badgeVariant === 'default') &&
'bg-muted text-muted-foreground'
)}
>
{item.badge}
</span>
)}
</NavLink>
</li>
);
})}
</ul>
</AccordionContent>
</AccordionItem>
);
}
export default NavGroup;

View File

@@ -9,6 +9,8 @@ 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 { Checkbox } from '@/components/ui/Checkbox';
import { QualityBadge } from '@/components/shared/QualityBadge';
import {
Copy,
Trash2,
@@ -31,6 +33,12 @@ export interface PromptCardProps {
actionsDisabled?: boolean;
/** Default expanded state */
defaultExpanded?: boolean;
/** Selection state for batch operations */
selected?: boolean;
/** Called when selection state changes */
onSelectChange?: (id: string, selected: boolean) => void;
/** Whether selection mode is active */
selectionMode?: boolean;
}
/**
@@ -66,6 +74,9 @@ export function PromptCard({
className,
actionsDisabled = false,
defaultExpanded = false,
selected = false,
onSelectChange,
selectionMode = false,
}: PromptCardProps) {
const { formatMessage } = useIntl();
const [expanded, setExpanded] = React.useState(defaultExpanded);
@@ -91,12 +102,44 @@ export function PromptCard({
setExpanded((prev) => !prev);
};
const handleSelectionChange = (checked: boolean) => {
onSelectChange?.(prompt.id, checked);
};
const handleCardClick = (e: React.MouseEvent) => {
if (selectionMode && (e.target as HTMLElement).closest('.prompt-card-checkbox')) {
return;
}
if (selectionMode) {
handleSelectionChange(!selected);
}
};
return (
<Card className={cn('transition-all duration-200', className)}>
<Card
className={cn(
'transition-all duration-200',
selected && 'ring-2 ring-primary',
selectionMode && 'cursor-pointer',
className
)}
onClick={handleCardClick}
>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
{/* Checkbox for selection mode */}
{selectionMode && (
<div className="prompt-card-checkbox">
<Checkbox
checked={selected}
onCheckedChange={handleSelectionChange}
className="mt-1"
/>
</div>
)}
{/* Title and metadata */}
<div className="flex-1 min-w-0">
<div className={cn('flex-1 min-w-0', !selectionMode && 'ml-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' })}
@@ -106,6 +149,7 @@ export function PromptCard({
{prompt.category}
</Badge>
)}
<QualityBadge qualityScore={prompt.quality_score} className="text-xs" />
</div>
{/* Metadata */}

View File

@@ -6,7 +6,13 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
import { MessageSquare, FileType, Hash } from 'lucide-react';
import { MessageSquare, FileType, Hash, Star } from 'lucide-react';
export interface QualityDistribution {
high: number;
medium: number;
low: number;
}
export interface PromptStatsProps {
/** Total number of prompts */
@@ -15,6 +21,10 @@ export interface PromptStatsProps {
avgLength: number;
/** Most common intent/category */
topIntent: string | null;
/** Average quality score (0-100) */
avgQualityScore?: number;
/** Quality distribution */
qualityDistribution?: QualityDistribution;
/** Loading state */
isLoading?: boolean;
}
@@ -22,15 +32,18 @@ export interface PromptStatsProps {
/**
* PromptStats component - displays prompt history statistics
*
* Shows three key metrics:
* Shows four key metrics:
* - Total prompts: overall count of stored prompts
* - Average length: mean character count across all prompts
* - Top intent: most frequently used category
* - Quality: average quality score with distribution
*/
export function PromptStats({
totalCount,
avgLength,
topIntent,
avgQualityScore,
qualityDistribution,
isLoading = false,
}: PromptStatsProps) {
const { formatMessage } = useIntl();
@@ -44,7 +57,7 @@ export function PromptStats({
};
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
<div className="grid gap-4 grid-cols-1 md:grid-cols-4">
<StatCard
title={formatMessage({ id: 'prompts.stats.totalCount' })}
value={totalCount}
@@ -69,6 +82,17 @@ export function PromptStats({
isLoading={isLoading}
description={formatMessage({ id: 'prompts.stats.topIntentDesc' })}
/>
<StatCard
title={formatMessage({ id: 'prompts.stats.avgQuality' })}
value={avgQualityScore !== undefined ? Math.round(avgQualityScore) : 'N/A'}
icon={Star}
variant="warning"
isLoading={isLoading}
description={qualityDistribution
? `${formatMessage({ id: 'prompts.quality.high' })}: ${qualityDistribution.high} | ${formatMessage({ id: 'prompts.quality.medium' })}: ${qualityDistribution.medium} | ${formatMessage({ id: 'prompts.quality.low' })}: ${qualityDistribution.low}`
: formatMessage({ id: 'prompts.stats.avgQualityDesc' })
}
/>
</div>
);
}
@@ -78,7 +102,8 @@ export function PromptStats({
*/
export function PromptStatsSkeleton() {
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
<div className="grid gap-4 grid-cols-1 md:grid-cols-4">
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />
<StatCardSkeleton />

View File

@@ -0,0 +1,88 @@
// ========================================
// QualityBadge Component
// ========================================
// Badge component for displaying prompt quality score
import { useIntl } from 'react-intl';
import { Badge } from '@/components/ui/Badge';
import type { Prompt } from '@/types/store';
export interface QualityBadgeProps {
/** Quality score (0-100) */
qualityScore?: number;
/** Optional className */
className?: string;
}
/**
* Get quality level from score
*/
function getQualityLevel(score?: number): 'high' | 'medium' | 'low' | 'none' {
if (score === undefined || score === null) return 'none';
if (score >= 80) return 'high';
if (score >= 60) return 'medium';
return 'low';
}
/**
* Get badge variant for quality level
*/
function getBadgeVariant(level: 'high' | 'medium' | 'low' | 'none'): 'success' | 'warning' | 'secondary' | 'outline' {
switch (level) {
case 'high':
return 'success';
case 'medium':
return 'warning';
case 'low':
return 'secondary';
default:
return 'outline';
}
}
/**
* QualityBadge component - displays prompt quality score with color coding
*
* Quality levels:
* - High (>=80): Green badge
* - Medium (>=60): Yellow badge
* - Low (<60): Gray badge
* - No score: Outline badge
*/
export function QualityBadge({ qualityScore, className }: QualityBadgeProps) {
const { formatMessage } = useIntl();
const level = getQualityLevel(qualityScore);
const variant = getBadgeVariant(level);
const labelKey = `prompts.quality.${level}`;
const label = formatMessage({ id: labelKey });
if (level === 'none') {
return null;
}
return (
<Badge variant={variant} className={className}>
{qualityScore !== undefined && `${qualityScore} `}
{label}
</Badge>
);
}
/**
* Hook to get quality badge data for a prompt
*/
export function useQualityBadge(prompt: Prompt) {
const qualityScore = prompt.quality_score;
const level = getQualityLevel(qualityScore);
const variant = getBadgeVariant(level);
return {
qualityScore,
level,
variant,
hasQuality: qualityScore !== undefined && qualityScore !== null,
};
}
export default QualityBadge;

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border/50", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-2 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -10,7 +10,6 @@ import {
updateMemory,
deleteMemory,
type CoreMemory,
type MemoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
@@ -30,6 +29,8 @@ const STALE_TIME = 60 * 1000;
export interface MemoryFilter {
search?: string;
tags?: string[];
favorite?: boolean;
archived?: boolean;
}
export interface UseMemoryOptions {
@@ -93,6 +94,26 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
);
}
// Filter by favorite status (from metadata)
if (filter?.favorite === true) {
memories = memories.filter((m) => {
if (!m.metadata) return false;
try {
const metadata = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata;
return metadata.favorite === true;
} catch {
return false;
}
});
}
// Filter by archived status
if (filter?.archived === true) {
memories = memories.filter((m) => m.archived === true);
} else if (filter?.archived === false) {
memories = memories.filter((m) => m.archived !== true);
}
return memories;
})();
@@ -202,6 +223,64 @@ export function useDeleteMemory(): UseDeleteMemoryReturn {
};
}
export interface UseArchiveMemoryReturn {
archiveMemory: (memoryId: string) => Promise<void>;
isArchiving: boolean;
error: Error | null;
}
export function useArchiveMemory(): UseArchiveMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (memoryId: string) =>
fetch(`/api/core-memory/memories/${encodeURIComponent(memoryId)}/archive?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
credentials: 'same-origin',
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
return {
archiveMemory: mutation.mutateAsync,
isArchiving: mutation.isPending,
error: mutation.error,
};
}
export interface UseUnarchiveMemoryReturn {
unarchiveMemory: (memoryId: string) => Promise<void>;
isUnarchiving: boolean;
error: Error | null;
}
export function useUnarchiveMemory(): UseUnarchiveMemoryReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (memoryId: string) =>
fetch(`/api/core-memory/memories?path=${encodeURIComponent(projectPath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ id: memoryId, archived: false }),
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
},
});
return {
unarchiveMemory: mutation.mutateAsync,
isUnarchiving: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all memory mutations
*/
@@ -209,14 +288,20 @@ export function useMemoryMutations() {
const create = useCreateMemory();
const update = useUpdateMemory();
const remove = useDeleteMemory();
const archive = useArchiveMemory();
const unarchive = useUnarchiveMemory();
return {
createMemory: create.createMemory,
updateMemory: update.updateMemory,
deleteMemory: remove.deleteMemory,
archiveMemory: archive.archiveMemory,
unarchiveMemory: unarchive.unarchiveMemory,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
isArchiving: archive.isArchiving,
isUnarchiving: unarchive.isUnarchiving,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting || archive.isArchiving || unarchive.isUnarchiving,
};
}

View File

@@ -7,14 +7,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchPrompts,
fetchPromptInsights,
fetchInsightsHistory,
analyzePrompts,
deletePrompt,
batchDeletePrompts,
deleteInsight,
type Prompt,
type PromptInsight,
type Pattern,
type Suggestion,
type PromptsResponse,
type PromptInsightsResponse,
type InsightsHistoryResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -24,6 +25,7 @@ export const promptHistoryKeys = {
lists: () => [...promptHistoryKeys.all, 'list'] as const,
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
insightsHistory: () => [...promptHistoryKeys.all, 'insightsHistory'] as const,
};
// Default stale time: 30 seconds (prompts update less frequently)
@@ -32,6 +34,7 @@ const STALE_TIME = 30 * 1000;
export interface PromptHistoryFilter {
search?: string;
intent?: string;
project?: string;
dateRange?: { start: Date | null; end: Date | null };
}
@@ -43,12 +46,19 @@ export interface UsePromptHistoryOptions {
export interface UsePromptHistoryReturn {
prompts: Prompt[];
allPrompts: Prompt[];
totalPrompts: number;
promptsBySession: Record<string, Prompt[]>;
stats: {
totalCount: number;
avgLength: number;
topIntent: string | null;
avgQualityScore?: number;
qualityDistribution?: {
high: number;
medium: number;
low: number;
};
};
isLoading: boolean;
isFetching: boolean;
@@ -96,6 +106,10 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
prompts = prompts.filter((p) => p.category === filter.intent);
}
if (filter?.project) {
prompts = prompts.filter((p) => p.project === filter.project);
}
if (filter?.dateRange?.start || filter?.dateRange?.end) {
prompts = prompts.filter((p) => {
const date = new Date(p.createdAt);
@@ -132,6 +146,34 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
}
const topIntent = Object.entries(intentCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
// Calculate quality distribution
const qualityDistribution = {
high: 0,
medium: 0,
low: 0,
};
let totalQualityScore = 0;
let qualityScoreCount = 0;
for (const prompt of allPrompts) {
if (prompt.quality_score !== undefined && prompt.quality_score !== null) {
totalQualityScore += prompt.quality_score;
qualityScoreCount++;
if (prompt.quality_score >= 80) {
qualityDistribution.high++;
} else if (prompt.quality_score >= 60) {
qualityDistribution.medium++;
} else {
qualityDistribution.low++;
}
}
}
const avgQualityScore = qualityScoreCount > 0
? totalQualityScore / qualityScoreCount
: undefined;
const refetch = async () => {
await query.refetch();
};
@@ -142,12 +184,15 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
return {
prompts: filteredPrompts,
allPrompts,
totalPrompts: totalCount,
promptsBySession,
stats: {
totalCount: allPrompts.length,
avgLength,
topIntent,
avgQualityScore,
qualityDistribution,
},
isLoading: query.isLoading,
isFetching: query.isFetching,
@@ -157,6 +202,8 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
};
}
/**
* Hook for fetching prompt insights
*/
@@ -175,6 +222,28 @@ export function usePromptInsights(options: { enabled?: boolean; staleTime?: numb
});
}
/**
* Hook for fetching insights history (past CLI analyses)
*/
export function useInsightsHistory(options: {
limit?: number;
enabled?: boolean;
staleTime?: number;
} = {}) {
const { limit = 20, enabled = true, staleTime = STALE_TIME } = options;
const projectPath = useWorkflowStore(selectProjectPath);
const queryEnabled = enabled && !!projectPath;
return useQuery({
queryKey: promptHistoryKeys.insightsHistory(),
queryFn: () => fetchInsightsHistory(projectPath, limit),
staleTime,
enabled: queryEnabled,
retry: 2,
});
}
// ========== Mutations ==========
export interface UseAnalyzePromptsReturn {
@@ -244,18 +313,120 @@ export function useDeletePrompt(): UseDeletePromptReturn {
};
}
export interface UseBatchDeletePromptsReturn {
batchDeletePrompts: (promptIds: string[]) => Promise<{ deleted: number }>;
isBatchDeleting: boolean;
error: Error | null;
}
export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: batchDeletePrompts,
onMutate: async (promptIds) => {
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) => !promptIds.includes(p.id)),
total: old.total - promptIds.length,
};
});
return { previousPrompts };
},
onError: (_error, _promptIds, context) => {
if (context?.previousPrompts) {
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
},
});
return {
batchDeletePrompts: mutation.mutateAsync,
isBatchDeleting: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteInsightReturn {
deleteInsight: (insightId: string) => Promise<{ success: boolean }>;
isDeleting: boolean;
error: Error | null;
}
export function useDeleteInsight(): UseDeleteInsightReturn {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const mutation = useMutation({
mutationFn: (insightId: string) => deleteInsight(insightId, projectPath),
onMutate: async (insightId) => {
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.insightsHistory() });
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory());
queryClient.setQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory(), (old) => {
if (!old) return old;
return {
...old,
insights: old.insights.filter((i) => i.id !== insightId),
};
});
return { previousInsights };
},
onError: (_error, _insightId, context) => {
if (context?.previousInsights) {
queryClient.setQueryData(promptHistoryKeys.insightsHistory(), context.previousInsights);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insightsHistory() });
},
});
return {
deleteInsight: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all prompt history mutations
*/
export function usePromptHistoryMutations() {
const analyze = useAnalyzePrompts();
const remove = useDeletePrompt();
const batchRemove = useBatchDeletePrompts();
return {
analyzePrompts: analyze.analyzePrompts,
deletePrompt: remove.deletePrompt,
batchDeletePrompts: batchRemove.batchDeletePrompts,
isAnalyzing: analyze.isAnalyzing,
isDeleting: remove.isDeleting,
isMutating: analyze.isAnalyzing || remove.isDeleting,
isBatchDeleting: batchRemove.isBatchDeleting,
isMutating: analyze.isAnalyzing || remove.isDeleting || batchRemove.isBatchDeleting,
};
}
/**
* Extract unique projects from prompts list
*/
export function extractUniqueProjects(prompts: Prompt[]): string[] {
const projectsSet = new Set<string>();
for (const prompt of prompts) {
if (prompt.project) {
projectsSet.add(prompt.project);
}
}
return Array.from(projectsSet).sort();
}

View File

@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import {
OrchestratorMessageSchema,
type OrchestratorWebSocketMessage,
@@ -60,6 +61,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// CLI stream store for CLI output handling
const addOutput = useCliStreamStore((state) => state.addOutput);
// Coordinator store for coordinator state updates
const updateNodeStatus = useCoordinatorStore((state) => state.updateNodeStatus);
const addCoordinatorLog = useCoordinatorStore((state) => state.addLog);
const setActiveQuestion = useCoordinatorStore((state) => state.setActiveQuestion);
const markExecutionComplete = useCoordinatorStore((state) => state.markExecutionComplete);
const coordinatorExecutionId = useCoordinatorStore((state) => state.currentExecutionId);
// Handle incoming WebSocket messages
const handleMessage = useCallback(
(event: MessageEvent) => {
@@ -143,6 +151,56 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
return;
}
// Handle Coordinator messages
if (data.type?.startsWith('COORDINATOR_')) {
// Only process messages for current coordinator execution
if (coordinatorExecutionId && data.executionId !== coordinatorExecutionId) {
return;
}
// Dispatch to coordinator store based on message type
switch (data.type) {
case 'COORDINATOR_STATE_UPDATE':
// Check for completion
if (data.status === 'completed') {
markExecutionComplete(true);
} else if (data.status === 'failed') {
markExecutionComplete(false);
}
break;
case 'COORDINATOR_COMMAND_STARTED':
updateNodeStatus(data.nodeId, 'running');
break;
case 'COORDINATOR_COMMAND_COMPLETED':
updateNodeStatus(data.nodeId, 'completed', data.result);
break;
case 'COORDINATOR_COMMAND_FAILED':
updateNodeStatus(data.nodeId, 'failed', undefined, data.error);
break;
case 'COORDINATOR_LOG_ENTRY':
addCoordinatorLog(
data.log.message,
data.log.level,
data.log.nodeId,
data.log.source
);
break;
case 'COORDINATOR_QUESTION_ASKED':
setActiveQuestion(data.question);
break;
case 'COORDINATOR_ANSWER_RECEIVED':
// Answer received - handled by submitAnswer in the store
break;
}
return;
}
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;
@@ -210,6 +268,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
},
[
currentExecution,
coordinatorExecutionId,
setWsLastMessage,
setExecutionStatus,
setNodeStarted,
@@ -220,6 +279,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
updateNode,
addOutput,
addA2UINotification,
updateNodeStatus,
addCoordinatorLog,
setActiveQuestion,
markExecutionComplete,
onMessage,
]
);

View File

@@ -996,7 +996,7 @@ export interface CommandsResponse {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchCommands(projectPath?: string): Promise<CommandsResponse> {
// Try with project path first, fall back to global on 403/404
// Try with project path first, fall back to global on errors
if (projectPath) {
try {
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
@@ -1017,30 +1017,41 @@ export async function fetchCommands(projectPath?: string): Promise<CommandsRespo
};
} catch (error: unknown) {
const apiError = error as ApiError;
if (apiError.status === 403 || apiError.status === 404) {
// Fall back to global commands list
console.warn('[fetchCommands] 403/404 for project path, falling back to global commands');
if (apiError.status === 403 || apiError.status === 404 || apiError.status === 400) {
// Fall back to global commands list on path validation errors
console.warn('[fetchCommands] Path validation failed, falling back to global commands');
} else {
throw error;
}
}
}
// Fallback: fetch global commands
const data = await fetchApi<{
commands?: Command[];
projectCommands?: Command[];
userCommands?: Command[];
groups?: string[];
projectGroupsConfig?: Record<string, any>;
userGroupsConfig?: Record<string, any>;
}>('/api/commands');
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
return {
commands: data.commands ?? allCommands,
groups: data.groups,
projectGroupsConfig: data.projectGroupsConfig,
userGroupsConfig: data.userGroupsConfig,
};
try {
const data = await fetchApi<{
commands?: Command[];
projectCommands?: Command[];
userCommands?: Command[];
groups?: string[];
projectGroupsConfig?: Record<string, any>;
userGroupsConfig?: Record<string, any>;
}>('/api/commands');
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
return {
commands: data.commands ?? allCommands,
groups: data.groups,
projectGroupsConfig: data.projectGroupsConfig,
userGroupsConfig: data.userGroupsConfig,
};
} catch (error) {
// If global fetch also fails, return empty data instead of throwing
console.warn('[fetchCommands] Failed to fetch commands, returning empty data:', error);
return {
commands: [],
groups: [],
projectGroupsConfig: {},
userGroupsConfig: {},
};
}
}
/**
@@ -1095,6 +1106,8 @@ export interface CoreMemory {
source?: string;
tags?: string[];
size?: number;
metadata?: string | Record<string, any>;
archived?: boolean;
}
export interface MemoryResponse {
@@ -1111,7 +1124,7 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
// Try with project path first, fall back to global on 403/404
if (projectPath) {
try {
const url = `/api/memory?path=${encodeURIComponent(projectPath)}`;
const url = `/api/core-memory/memories?path=${encodeURIComponent(projectPath)}`;
const data = await fetchApi<{
memories?: CoreMemory[];
totalSize?: number;
@@ -1137,7 +1150,7 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
memories?: CoreMemory[];
totalSize?: number;
claudeMdCount?: number;
}>('/api/memory');
}>('/api/core-memory/memories');
return {
memories: data.memories ?? [],
totalSize: data.totalSize ?? 0,
@@ -1153,12 +1166,16 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
export async function createMemory(input: {
content: string;
tags?: string[];
metadata?: Record<string, any>;
}, projectPath?: string): Promise<CoreMemory> {
const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory';
return fetchApi<CoreMemory>(url, {
const url = '/api/core-memory/memories';
return fetchApi<{ success: boolean; memory: CoreMemory }>(url, {
method: 'POST',
body: JSON.stringify(input),
});
body: JSON.stringify({
...input,
path: projectPath,
}),
}).then(data => data.memory);
}
/**
@@ -1172,13 +1189,15 @@ export async function updateMemory(
input: Partial<CoreMemory>,
projectPath?: string
): Promise<CoreMemory> {
const url = projectPath
? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
: `/api/memory/${encodeURIComponent(memoryId)}`;
return fetchApi<CoreMemory>(url, {
method: 'PATCH',
body: JSON.stringify(input),
});
const url = '/api/core-memory/memories';
return fetchApi<{ success: boolean; memory: CoreMemory }>(url, {
method: 'POST',
body: JSON.stringify({
id: memoryId,
...input,
path: projectPath,
}),
}).then(data => data.memory);
}
/**
@@ -1188,8 +1207,8 @@ export async function updateMemory(
*/
export async function deleteMemory(memoryId: string, projectPath?: string): Promise<void> {
const url = projectPath
? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
: `/api/memory/${encodeURIComponent(memoryId)}`;
? `/api/core-memory/memories/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
: `/api/core-memory/memories/${encodeURIComponent(memoryId)}`;
return fetchApi<void>(url, {
method: 'DELETE',
});
@@ -2329,6 +2348,35 @@ export interface PromptInsightsResponse {
suggestions: Suggestion[];
}
/**
* Insight history entry from CLI analysis
*/
export interface InsightHistory {
/** Unique insight identifier */
id: string;
/** Created timestamp */
created_at: string;
/** AI tool used for analysis */
tool: 'gemini' | 'qwen' | 'codex' | string;
/** Number of prompts analyzed */
prompt_count: number;
/** Detected patterns */
patterns: Pattern[];
/** AI suggestions */
suggestions: Suggestion[];
/** Associated execution ID */
execution_id: string | null;
/** Language preference */
lang: string;
}
/**
* Insights history response from backend
*/
export interface InsightsHistoryResponse {
insights: InsightHistory[];
}
/**
* Analyze prompts request
*/
@@ -2356,6 +2404,30 @@ export async function fetchPromptInsights(projectPath?: string): Promise<PromptI
return fetchApi<PromptInsightsResponse>(url);
}
/**
* Fetch insights history (past CLI analyses) from backend
* @param projectPath - Optional project path to filter data by workspace
* @param limit - Maximum number of insights to fetch (default: 20)
*/
export async function fetchInsightsHistory(projectPath?: string, limit: number = 20): Promise<InsightsHistoryResponse> {
const url = projectPath
? `/api/memory/insights?limit=${limit}&path=${encodeURIComponent(projectPath)}`
: `/api/memory/insights?limit=${limit}`;
return fetchApi<InsightsHistoryResponse>(url);
}
/**
* Fetch a single insight detail by ID
* @param insightId - Insight ID to fetch
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchInsightDetail(insightId: string, projectPath?: string): Promise<InsightHistory> {
const url = projectPath
? `/api/memory/insights/${encodeURIComponent(insightId)}?path=${encodeURIComponent(projectPath)}`
: `/api/memory/insights/${encodeURIComponent(insightId)}`;
return fetchApi<InsightHistory>(url);
}
/**
* Analyze prompts using AI tool
*/
@@ -2375,6 +2447,28 @@ export async function deletePrompt(promptId: string): Promise<void> {
});
}
/**
* Delete an insight from history
*/
export async function deleteInsight(insightId: string, projectPath?: string): Promise<{ success: boolean }> {
const url = projectPath
? `/api/memory/insights/${encodeURIComponent(insightId)}?path=${encodeURIComponent(projectPath)}`
: `/api/memory/insights/${encodeURIComponent(insightId)}`;
return fetchApi<{ success: boolean }>(url, {
method: 'DELETE',
});
}
/**
* Batch delete prompts from history
*/
export async function batchDeletePrompts(promptIds: string[]): Promise<{ deleted: number }> {
return fetchApi<{ deleted: number }>('/api/memory/prompts/batch-delete', {
method: 'POST',
body: JSON.stringify({ promptIds }),
});
}
// ========== File Explorer API ==========
/**
@@ -3844,6 +3938,41 @@ export interface ExecutionStateResponse {
elapsedMs: number;
}
/**
* Coordinator pipeline details response
*/
export interface CoordinatorPipelineDetails {
id: string;
name: string;
description?: string;
nodes: Array<{
id: string;
name: string;
description?: string;
command: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
startedAt?: string;
completedAt?: string;
result?: unknown;
error?: string;
output?: string;
parentId?: string;
children?: Array<any>;
}>;
totalSteps: number;
estimatedDuration?: number;
logs?: Array<{
id: string;
timestamp: string;
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
}>;
status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
createdAt: string;
}
/**
* Execution log entry
*/
@@ -3874,6 +4003,14 @@ export async function fetchExecutionState(execId: string): Promise<{ success: bo
return fetchApi(`/api/orchestrator/executions/${encodeURIComponent(execId)}`);
}
/**
* Fetch coordinator pipeline details by execution ID
* @param execId - Execution/Pipeline ID
*/
export async function fetchCoordinatorPipeline(execId: string): Promise<{ success: boolean; data: CoordinatorPipelineDetails }> {
return fetchApi(`/api/coordinator/pipeline/${encodeURIComponent(execId)}`);
}
/**
* Fetch execution logs with pagination and filtering
* @param execId - Execution ID

View File

@@ -289,8 +289,22 @@
"descriptionPlaceholder": "Optional description for this configuration",
"selectProvider": "Select a provider",
"includeCoAuthoredBy": "Include co-authored-by in commits",
"coAuthoredBy": "Co-authored",
"availableModels": "Available Models",
"availableModelsPlaceholder": "Enter model name and press Enter",
"availableModelsHint": "Models shown in CLI dropdown menus. Click × to remove.",
"nameFormatHint": "Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]",
"nameTooLong": "Name must be {max} characters or less",
"settingsFile": "Settings File",
"settingsFilePlaceholder": "e.g., /path/to/settings.json",
"settingsFileHint": "Path to external Claude CLI settings file (passed via --settings parameter)",
"tags": "Tags",
"tagsDescription": "Tags for CLI tool routing and auto-selection (e.g., analysis, Debug)",
"addTag": "Add tag",
"tagInputPlaceholder": "Enter a tag...",
"predefinedTags": "Common tags",
"removeTag": "Remove tag",
"noTags": "No tags added",
"validation": {
"providerRequired": "Please select a provider",
"authOrBaseUrlRequired": "Please enter auth token or base URL"

View File

@@ -181,6 +181,8 @@
"tutorials": "Tutorials"
}
},
"yes": "Yes",
"no": "No",
"askQuestion": {
"defaultTitle": "Questions",
"description": "Please answer the following questions",
@@ -188,5 +190,71 @@
"yes": "Yes",
"no": "No",
"required": "This question is required"
},
"coordinator": {
"modal": {
"title": "Start Coordinator",
"description": "Describe the task you want the coordinator to execute"
},
"form": {
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (minimum 10 characters)...",
"parameters": "Parameters (Optional)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "Optional JSON parameters for the coordinator execution",
"characterCount": "{current} / {max} characters (min: {min})",
"start": "Start Coordinator",
"starting": "Starting..."
},
"validation": {
"taskDescriptionRequired": "Task description is required",
"taskDescriptionTooShort": "Task description must be at least 10 characters",
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
"parametersInvalidJson": "Parameters must be valid JSON",
"answerRequired": "An answer is required"
},
"success": {
"started": "Coordinator started successfully"
},
"status": {
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"skipped": "Skipped"
},
"logs": "Logs",
"entries": "entries",
"error": "Error",
"output": "Output",
"startedAt": "Started At",
"completedAt": "Completed At",
"retrying": "Retrying...",
"retry": "Retry",
"skipping": "Skipping...",
"skip": "Skip",
"logLevel": "Log Level",
"level": {
"all": "All",
"info": "Info",
"warn": "Warning",
"error": "Error",
"debug": "Debug"
},
"noLogs": "No logs available",
"question": {
"answer": "Answer",
"textPlaceholder": "Enter your answer...",
"selectOne": "Select One",
"selectMultiple": "Select Multiple",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"submitting": "Submitting...",
"submit": "Submit"
},
"error": {
"submitFailed": "Failed to submit answer"
}
}
}

View File

@@ -6,10 +6,17 @@
"edit": "Edit",
"delete": "Delete",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyError": "Failed to copy",
"refresh": "Refresh",
"expand": "Expand",
"collapse": "Collapse"
},
"tabs": {
"memories": "Memories",
"favorites": "Favorites",
"archived": "Archived"
},
"stats": {
"totalSize": "Total Size",
"count": "Count",
@@ -43,7 +50,9 @@
"editTitle": "Edit Memory",
"labels": {
"content": "Content",
"tags": "Tags"
"tags": "Tags",
"favorite": "Favorite",
"priority": "Priority"
},
"placeholders": {
"content": "Enter memory content...",
@@ -57,6 +66,11 @@
"updating": "Updating..."
}
},
"priority": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"types": {
"coreMemory": "Core Memory",
"workflow": "Workflow",

View File

@@ -1,4 +1,12 @@
{
"groups": {
"overview": "Overview",
"workflow": "Workflow & Execution",
"knowledge": "Knowledge & Memory",
"issues": "Issue Management",
"tools": "Tools & Hooks",
"configuration": "Configuration & Support"
},
"main": {
"home": "Home",
"sessions": "Sessions",

View File

@@ -48,5 +48,7 @@
"atTime": "at {0}"
},
"markAsRead": "Mark as read",
"markAsUnread": "Mark as unread"
"markAsUnread": "Mark as unread",
"read": "Read",
"unread": "Unread"
}

View File

@@ -3,6 +3,7 @@
"description": "View and analyze your prompt history with AI insights",
"searchPlaceholder": "Search prompts...",
"filterByIntent": "Filter by intent",
"filterByProject": "Filter by project",
"intents": {
"all": "All Intents",
"intent": "Intent",
@@ -12,6 +13,10 @@
"document": "Document",
"analyze": "Analyze"
},
"projects": {
"all": "All Projects",
"project": "Project"
},
"stats": {
"totalCount": "Total Prompts",
"totalCountDesc": "All stored prompts",
@@ -19,7 +24,15 @@
"avgLengthDesc": "Mean character count",
"topIntent": "Top Intent",
"topIntentDesc": "Most used category",
"noIntent": "N/A"
"noIntent": "N/A",
"avgQuality": "Avg Quality",
"avgQualityDesc": "Quality score distribution"
},
"quality": {
"high": "High",
"medium": "Medium",
"low": "Low",
"none": "N/A"
},
"card": {
"untitled": "Untitled Prompt",
@@ -52,6 +65,24 @@
"suggestions": "Suggestions"
}
},
"insightsHistory": {
"loading": "Loading insights history...",
"patterns": "Patterns",
"suggestions": "Suggestions",
"prompts": "Prompts",
"empty": {
"title": "No analysis history",
"message": "Run an analysis to see historical insights and patterns."
}
},
"insightDetail": {
"title": "Insight Detail",
"patterns": "Patterns Found",
"suggestions": "Suggestions",
"promptsAnalyzed": "prompts analyzed",
"noContent": "No patterns or suggestions available for this insight.",
"deleting": "Deleting..."
},
"suggestions": {
"types": {
"refactor": "Refactor",
@@ -63,7 +94,15 @@
},
"dialog": {
"deleteTitle": "Delete Prompt",
"deleteConfirm": "Are you sure you want to delete this prompt? This action cannot be undone."
"deleteConfirm": "Are you sure you want to delete this prompt? This action cannot be undone.",
"batchDeleteTitle": "Delete Prompts",
"batchDeleteConfirm": "Are you sure you want to delete {count} selected prompt(s)? This action cannot be undone."
},
"batch": {
"selected": "{count} selected",
"selectAll": "Select All",
"clearSelection": "Clear Selection",
"deleteSelected": "Delete Selected"
},
"emptyState": {
"title": "No prompts found",

View File

@@ -289,8 +289,22 @@
"descriptionPlaceholder": "此配置的可选描述",
"selectProvider": "选择提供商",
"includeCoAuthoredBy": "在提交中包含 co-authored-by",
"coAuthoredBy": "共同创作",
"availableModels": "可用模型",
"availableModelsPlaceholder": "输入模型名称并按回车",
"availableModelsHint": "显示在 CLI 下拉菜单中的模型。点击 × 删除。",
"nameFormatHint": "仅限字母、数字、连字符和下划线。用作ccw cli --tool [名称]",
"nameTooLong": "名称必须在 {max} 个字符以内",
"settingsFile": "配置文件路径",
"settingsFilePlaceholder": "例如:/path/to/settings.json",
"settingsFileHint": "外部 Claude CLI 配置文件路径(通过 --settings 参数传递)",
"tags": "标签",
"tagsDescription": "CLI 工具路由和自动选择标签例如分析、Debug",
"addTag": "添加标签",
"tagInputPlaceholder": "输入标签...",
"predefinedTags": "常用标签",
"removeTag": "删除标签",
"noTags": "未添加标签",
"validation": {
"providerRequired": "请选择提供商",
"authOrBaseUrlRequired": "请输入认证令牌或基础 URL"

View File

@@ -185,6 +185,8 @@
"tutorials": "教程"
}
},
"yes": "是",
"no": "否",
"askQuestion": {
"defaultTitle": "问题",
"description": "请回答以下问题",
@@ -192,5 +194,71 @@
"yes": "是",
"no": "否",
"required": "此问题为必填项"
},
"coordinator": {
"modal": {
"title": "启动协调器",
"description": "描述您希望协调器执行的任务"
},
"form": {
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "描述协调器需要执行的任务至少10个字符...",
"parameters": "参数(可选)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "协调器执行的可选JSON参数",
"characterCount": "{current} / {max} 字符(最少:{min}",
"start": "启动协调器",
"starting": "启动中..."
},
"validation": {
"taskDescriptionRequired": "任务描述为必填项",
"taskDescriptionTooShort": "任务描述至少需要10个字符",
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
"parametersInvalidJson": "参数必须是有效的JSON格式",
"answerRequired": "答案为必填项"
},
"success": {
"started": "协调器启动成功"
},
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"skipped": "已跳过"
},
"logs": "日志",
"entries": "条日志",
"error": "错误",
"output": "输出",
"startedAt": "开始时间",
"completedAt": "完成时间",
"retrying": "重试中...",
"retry": "重试",
"skipping": "跳过中...",
"skip": "跳过",
"logLevel": "日志级别",
"level": {
"all": "全部",
"info": "信息",
"warn": "警告",
"error": "错误",
"debug": "调试"
},
"noLogs": "无可用日志",
"question": {
"answer": "答案",
"textPlaceholder": "输入您的答案...",
"selectOne": "选择一个",
"selectMultiple": "选择多个",
"confirm": "确认",
"yes": "是",
"no": "否",
"submitting": "提交中...",
"submit": "提交"
},
"error": {
"submitFailed": "提交答案失败"
}
}
}

View File

@@ -6,10 +6,17 @@
"edit": "编辑",
"delete": "删除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyError": "复制失败",
"refresh": "刷新",
"expand": "展开",
"collapse": "收起"
},
"tabs": {
"memories": "记忆",
"favorites": "收藏",
"archived": "归档"
},
"stats": {
"totalSize": "总大小",
"count": "数量",
@@ -43,7 +50,9 @@
"editTitle": "编辑记忆",
"labels": {
"content": "内容",
"tags": "标签"
"tags": "标签",
"favorite": "收藏",
"priority": "优先级"
},
"placeholders": {
"content": "输入记忆内容...",
@@ -57,6 +66,11 @@
"updating": "更新中..."
}
},
"priority": {
"low": "低",
"medium": "中",
"high": "高"
},
"types": {
"coreMemory": "核心记忆",
"workflow": "工作流",

View File

@@ -1,4 +1,12 @@
{
"groups": {
"overview": "概览",
"workflow": "工作流与执行",
"knowledge": "知识与记忆",
"issues": "问题管理",
"tools": "工具与钩子",
"configuration": "配置与支持"
},
"main": {
"home": "首页",
"sessions": "会话",

View File

@@ -48,5 +48,7 @@
"atTime": "{0}"
},
"markAsRead": "标为已读",
"markAsUnread": "标为未读"
"markAsUnread": "标为未读",
"read": "已读",
"unread": "未读"
}

View File

@@ -3,6 +3,7 @@
"description": "查看和分析您的提示历史记录,获取 AI 洞察",
"searchPlaceholder": "搜索提示...",
"filterByIntent": "按意图筛选",
"filterByProject": "按项目筛选",
"intents": {
"all": "所有意图",
"intent": "意图",
@@ -12,6 +13,10 @@
"document": "文档",
"analyze": "分析"
},
"projects": {
"all": "所有项目",
"project": "项目"
},
"stats": {
"totalCount": "总提示数",
"totalCountDesc": "所有存储的提示",
@@ -19,7 +24,15 @@
"avgLengthDesc": "平均字符数",
"topIntent": "主要意图",
"topIntentDesc": "最常用的类别",
"noIntent": "无"
"noIntent": "无",
"avgQuality": "平均质量",
"avgQualityDesc": "质量分数分布"
},
"quality": {
"high": "高",
"medium": "中",
"low": "低",
"none": "无"
},
"card": {
"untitled": "未命名提示",
@@ -52,6 +65,24 @@
"suggestions": "建议"
}
},
"insightsHistory": {
"loading": "加载洞察历史...",
"patterns": "模式",
"suggestions": "建议",
"prompts": "提示",
"empty": {
"title": "暂无分析历史",
"message": "运行分析以查看历史洞察和模式。"
}
},
"insightDetail": {
"title": "洞察详情",
"patterns": "发现的模式",
"suggestions": "建议",
"promptsAnalyzed": "个提示已分析",
"noContent": "此洞察暂无模式或建议。",
"deleting": "删除中..."
},
"suggestions": {
"types": {
"refactor": "重构",
@@ -63,7 +94,15 @@
},
"dialog": {
"deleteTitle": "删除提示",
"deleteConfirm": "确定要删除此提示吗?此操作无法撤销。"
"deleteConfirm": "确定要删除此提示吗?此操作无法撤销。",
"batchDeleteTitle": "批量删除提示",
"batchDeleteConfirm": "确定要删除 {count} 个选中的提示吗?此操作无法撤销。"
},
"batch": {
"selected": "已选 {count} 个",
"selectAll": "全选",
"clearSelection": "清除选择",
"deleteSelected": "删除选中"
},
"emptyState": {
"title": "未找到提示",

View File

@@ -3,8 +3,9 @@
// ========================================
// View and manage core memory and context with CRUD operations
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import {
Brain,
Search,
@@ -19,12 +20,16 @@ import {
Copy,
ChevronDown,
ChevronUp,
Star,
Archive,
ArchiveRestore,
} 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Checkbox } from '@/components/ui/Checkbox';
import { useMemory, useMemoryMutations } from '@/hooks';
import type { CoreMemory } from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -38,10 +43,19 @@ interface MemoryCardProps {
onEdit: (memory: CoreMemory) => void;
onDelete: (memory: CoreMemory) => void;
onCopy: (content: string) => void;
onToggleFavorite: (memory: CoreMemory) => void;
onArchive: (memory: CoreMemory) => void;
onUnarchive: (memory: CoreMemory) => void;
}
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy }: MemoryCardProps) {
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy, onToggleFavorite, onArchive, onUnarchive }: MemoryCardProps) {
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
// Parse metadata from memory
const metadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
const isFavorite = metadata.favorite === true;
const priority = metadata.priority || 'medium';
const isArchived = memory.archived || false;
const formattedSize = memory.size
? memory.size < 1024
? `${memory.size} B`
@@ -70,6 +84,16 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
{memory.source}
</Badge>
)}
{priority !== 'medium' && (
<Badge variant={priority === 'high' ? 'destructive' : 'secondary'} className="text-xs">
{priority}
</Badge>
)}
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">
{formattedDate} - {formattedSize}
@@ -77,6 +101,17 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className={cn("h-8 w-8 p-0", isFavorite && "text-yellow-500")}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(memory);
}}
>
<Star className={cn("w-4 h-4", isFavorite && "fill-current")} />
</Button>
<Button
variant="ghost"
size="sm"
@@ -99,6 +134,31 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
>
<Edit className="w-4 h-4" />
</Button>
{!isArchived ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onArchive(memory);
}}
>
<Archive className="w-4 h-4" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onUnarchive(memory);
}}
>
<ArchiveRestore className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -160,7 +220,7 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
interface NewMemoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { content: string; tags?: string[] }) => void;
onSubmit: (data: { content: string; tags?: string[]; metadata?: Record<string, any> }) => void;
isCreating: boolean;
editingMemory?: CoreMemory | null;
}
@@ -175,6 +235,27 @@ function NewMemoryDialog({
const { formatMessage } = useIntl();
const [content, setContent] = useState(editingMemory?.content || '');
const [tagsInput, setTagsInput] = useState(editingMemory?.tags?.join(', ') || '');
const [isFavorite, setIsFavorite] = useState(false);
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
// Initialize from editing memory metadata
useEffect(() => {
if (editingMemory && editingMemory.metadata) {
try {
const metadata = typeof editingMemory.metadata === 'string'
? JSON.parse(editingMemory.metadata)
: editingMemory.metadata;
setIsFavorite(metadata.favorite === true);
setPriority(metadata.priority || 'medium');
} catch {
setIsFavorite(false);
setPriority('medium');
}
} else {
setIsFavorite(false);
setPriority('medium');
}
}, [editingMemory]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -183,9 +264,21 @@ function NewMemoryDialog({
.split(',')
.map((t) => t.trim())
.filter(Boolean);
onSubmit({ content: content.trim(), tags: tags.length > 0 ? tags : undefined });
// Build metadata object
const metadata: Record<string, any> = {};
if (isFavorite) metadata.favorite = true;
if (priority !== 'medium') metadata.priority = priority;
onSubmit({
content: content.trim(),
tags: tags.length > 0 ? tags : undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
});
setContent('');
setTagsInput('');
setIsFavorite(false);
setPriority('medium');
}
};
@@ -217,6 +310,30 @@ function NewMemoryDialog({
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="favorite"
checked={isFavorite}
onCheckedChange={(checked) => setIsFavorite(checked === true)}
/>
<label htmlFor="favorite" className="text-sm font-medium cursor-pointer">
{formatMessage({ id: 'memory.createDialog.labels.favorite' })}
</label>
</div>
<div>
<label className="text-sm font-medium">{formatMessage({ id: 'memory.createDialog.labels.priority' })}</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
className="mt-1 w-full p-2 bg-background border border-input rounded-md text-sm"
>
<option value="low">{formatMessage({ id: 'memory.priority.low' })}</option>
<option value="medium">{formatMessage({ id: 'memory.priority.medium' })}</option>
<option value="high">{formatMessage({ id: 'memory.priority.high' })}</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{formatMessage({ id: 'memory.createDialog.buttons.cancel' })}
@@ -250,6 +367,11 @@ export function MemoryPage() {
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(new Set());
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived'>('memories');
// Build filter based on current tab
const favoriteFilter = currentTab === 'favorites' ? { favorite: true } : undefined;
const archivedFilter = currentTab === 'archived' ? { archived: true } : { archived: false };
const {
memories,
@@ -263,10 +385,12 @@ export function MemoryPage() {
filter: {
search: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
...favoriteFilter,
...archivedFilter,
},
});
const { createMemory, updateMemory, deleteMemory, isCreating, isUpdating } =
const { createMemory, updateMemory, deleteMemory, archiveMemory, unarchiveMemory, isCreating, isUpdating } =
useMemoryMutations();
const toggleExpand = (memoryId: string) => {
@@ -281,12 +405,12 @@ export function MemoryPage() {
});
};
const handleCreateMemory = async (data: { content: string; tags?: string[] }) => {
const handleCreateMemory = async (data: { content: string; tags?: string[]; metadata?: Record<string, any> }) => {
if (editingMemory) {
await updateMemory(editingMemory.id, data);
setEditingMemory(null);
} else {
await createMemory(data);
await createMemory(data as any); // TODO: update createMemory type to accept metadata
}
setIsNewMemoryOpen(false);
};
@@ -302,12 +426,29 @@ export function MemoryPage() {
}
};
const handleToggleFavorite = async (memory: CoreMemory) => {
const currentMetadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
const newFavorite = !(currentMetadata.favorite === true);
await updateMemory(memory.id, {
metadata: JSON.stringify({ ...currentMetadata, favorite: newFavorite }),
} as any); // TODO: update updateMemory to accept metadata field
};
const handleArchive = async (memory: CoreMemory) => {
await archiveMemory(memory.id);
};
const handleUnarchive = async (memory: CoreMemory) => {
await unarchiveMemory(memory.id);
};
const copyToClipboard = async (content: string) => {
try {
await navigator.clipboard.writeText(content);
// TODO: Show toast notification
toast.success(formatMessage({ id: 'memory.actions.copySuccess' }));
} catch (err) {
console.error('Failed to copy:', err);
toast.error(formatMessage({ id: 'memory.actions.copyError' }));
}
};
@@ -348,6 +489,34 @@ export function MemoryPage() {
</div>
</div>
{/* Tab Navigation */}
<div className="flex items-center gap-2 border-b border-border">
<Button
variant={currentTab === 'memories' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentTab('memories')}
>
<Brain className="w-4 h-4 mr-2" />
{formatMessage({ id: 'memory.tabs.memories' })}
</Button>
<Button
variant={currentTab === 'favorites' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentTab('favorites')}
>
<Star className="w-4 h-4 mr-2" />
{formatMessage({ id: 'memory.tabs.favorites' })}
</Button>
<Button
variant={currentTab === 'archived' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCurrentTab('archived')}
>
<Archive className="w-4 h-4 mr-2" />
{formatMessage({ id: 'memory.tabs.archived' })}
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
@@ -429,9 +598,9 @@ export function MemoryPage() {
{/* Memory List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : memories.length === 0 ? (
@@ -449,7 +618,7 @@ export function MemoryPage() {
</Button>
</Card>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{memories.map((memory) => (
<MemoryCard
key={memory.id}
@@ -459,6 +628,9 @@ export function MemoryPage() {
onEdit={handleEdit}
onDelete={handleDelete}
onCopy={copyToClipboard}
onToggleFavorite={handleToggleFavorite}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
))}
</div>

View File

@@ -17,12 +17,20 @@ import {
import {
usePromptHistory,
usePromptInsights,
useInsightsHistory,
usePromptHistoryMutations,
useDeleteInsight,
extractUniqueProjects,
type PromptHistoryFilter,
} from '@/hooks/usePromptHistory';
import { PromptStats, PromptStatsSkeleton } from '@/components/shared/PromptStats';
import { PromptCard } from '@/components/shared/PromptCard';
import { BatchOperationToolbar } from '@/components/shared/BatchOperationToolbar';
import { InsightsPanel } from '@/components/shared/InsightsPanel';
import { InsightsHistoryList } from '@/components/shared/InsightsHistoryList';
import { InsightDetailPanelOverlay } from '@/components/shared/InsightDetailPanel';
import { fetchInsightDetail } from '@/lib/api';
import type { InsightHistory } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
@@ -42,7 +50,6 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
type IntentFilter = 'all' | string;
@@ -56,24 +63,35 @@ export function PromptHistoryPage() {
// Filter state
const [searchQuery, setSearchQuery] = React.useState('');
const [intentFilter, setIntentFilter] = React.useState<IntentFilter>('all');
const [projectFilter, setProjectFilter] = React.useState<string>('all');
const [selectedTool, setSelectedTool] = React.useState<'gemini' | 'qwen' | 'codex'>('gemini');
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [promptToDelete, setPromptToDelete] = React.useState<string | null>(null);
// Insight detail state
const [selectedInsight, setSelectedInsight] = React.useState<InsightHistory | null>(null);
const [insightDetailOpen, setInsightDetailOpen] = React.useState(false);
// Batch operations state
const [selectedPromptIds, setSelectedPromptIds] = React.useState<Set<string>>(new Set());
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = React.useState(false);
// Build filter object
const filter: PromptHistoryFilter = React.useMemo(
() => ({
search: searchQuery,
intent: intentFilter === 'all' ? undefined : intentFilter,
project: projectFilter === 'all' ? undefined : projectFilter,
}),
[searchQuery, intentFilter]
[searchQuery, intentFilter, projectFilter]
);
// Fetch prompts and insights
const {
prompts,
allPrompts,
promptsBySession,
stats,
isLoading,
@@ -83,10 +101,18 @@ export function PromptHistoryPage() {
} = usePromptHistory({ filter });
const { data: insightsData, isLoading: insightsLoading } = usePromptInsights();
const { data: insightsHistoryData, isLoading: insightsHistoryLoading } = useInsightsHistory({ limit: 20 });
const { analyzePrompts, deletePrompt, isAnalyzing } = usePromptHistoryMutations();
const { analyzePrompts, deletePrompt, batchDeletePrompts, isAnalyzing, isBatchDeleting } = usePromptHistoryMutations();
const { deleteInsight: deleteInsightMutation, isDeleting: isDeletingInsight } = useDeleteInsight();
const isMutating = isAnalyzing;
const isMutating = isAnalyzing || isBatchDeleting;
// Extract unique projects from all prompts
const uniqueProjects = React.useMemo(
() => extractUniqueProjects(allPrompts),
[allPrompts]
);
// Handlers
const handleAnalyze = async () => {
@@ -118,6 +144,45 @@ export function PromptHistoryPage() {
setSearchQuery('');
};
const handleInsightSelect = async (insightId: string) => {
try {
const insight = await fetchInsightDetail(insightId);
setSelectedInsight(insight);
setInsightDetailOpen(true);
} catch (err) {
console.error('Failed to fetch insight detail:', err);
}
};
const handleDeleteInsight = async (insightId: string) => {
const locale = useIntl().locale;
const confirmMessage = locale === 'zh'
? '确定要删除此分析吗?此操作无法撤销。'
: 'Are you sure you want to delete this insight? This action cannot be undone.';
if (!window.confirm(confirmMessage)) {
return;
}
try {
await deleteInsightMutation(insightId);
setInsightDetailOpen(false);
setSelectedInsight(null);
// Show success toast
const successMessage = locale === 'zh' ? '洞察已删除' : 'Insight deleted';
if (window.showToast) {
window.showToast(successMessage, 'success');
}
} catch (err) {
console.error('Failed to delete insight:', err);
// Show error toast
const errorMessage = locale === 'zh' ? '删除洞察失败' : 'Failed to delete insight';
if (window.showToast) {
window.showToast(errorMessage, 'error');
}
}
};
const toggleIntentFilter = (intent: string) => {
setIntentFilter((prev) => (prev === intent ? 'all' : intent));
};
@@ -125,9 +190,53 @@ export function PromptHistoryPage() {
const clearFilters = () => {
setSearchQuery('');
setIntentFilter('all');
setProjectFilter('all');
};
const hasActiveFilters = searchQuery.length > 0 || intentFilter !== 'all';
// Batch operations handlers
const handleSelectPrompt = (promptId: string, selected: boolean) => {
setSelectedPromptIds((prev) => {
const next = new Set(prev);
if (selected) {
next.add(promptId);
} else {
next.delete(promptId);
}
return next;
});
};
const handleSelectAll = (selected: boolean) => {
if (selected) {
setSelectedPromptIds(new Set(prompts.map((p) => p.id)));
} else {
setSelectedPromptIds(new Set());
}
};
const handleClearSelection = () => {
setSelectedPromptIds(new Set());
};
const handleBatchDeleteClick = () => {
if (selectedPromptIds.size > 0) {
setBatchDeleteDialogOpen(true);
}
};
const handleConfirmBatchDelete = async () => {
if (selectedPromptIds.size === 0) return;
try {
await batchDeletePrompts(Array.from(selectedPromptIds));
setBatchDeleteDialogOpen(false);
setSelectedPromptIds(new Set());
} catch (err) {
console.error('Failed to batch delete prompts:', err);
}
};
const hasActiveFilters = searchQuery.length > 0 || intentFilter !== 'all' || projectFilter !== 'all';
// Group prompts for timeline view
const timelineGroups = React.useMemo(() => {
@@ -247,9 +356,9 @@ export function PromptHistoryPage() {
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
{formatMessage({ id: 'common.actions.filter' })}
{intentFilter !== 'all' && (
{(intentFilter !== 'all' || projectFilter !== 'all') && (
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
{intentFilter}
{(intentFilter !== 'all' ? 1 : 0) + (projectFilter !== 'all' ? 1 : 0)}
</Badge>
)}
</Button>
@@ -274,6 +383,26 @@ export function PromptHistoryPage() {
{intentFilter === intent && <span className="text-primary">&#10003;</span>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>{formatMessage({ id: 'prompts.filterByProject' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setProjectFilter('all')}
className="justify-between"
>
<span>{formatMessage({ id: 'prompts.projects.all' })}</span>
{projectFilter === 'all' && <span className="text-primary">&#10003;</span>}
</DropdownMenuItem>
{uniqueProjects.map((project) => (
<DropdownMenuItem
key={project}
onClick={() => setProjectFilter(project)}
className="justify-between"
>
<span className="truncate max-w-32" title={project}>{project}</span>
{projectFilter === project && <span className="text-primary">&#10003;</span>}
</DropdownMenuItem>
))}
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
@@ -300,6 +429,16 @@ export function PromptHistoryPage() {
<X className="ml-1 h-3 w-3" />
</Badge>
)}
{projectFilter !== 'all' && (
<Badge
variant="secondary"
className="cursor-pointer"
onClick={() => setProjectFilter('all')}
>
{formatMessage({ id: 'prompts.projects.project' })}: {projectFilter}
<X className="ml-1 h-3 w-3" />
</Badge>
)}
{searchQuery && (
<Badge
variant="secondary"
@@ -316,6 +455,16 @@ export function PromptHistoryPage() {
</div>
)}
{/* Batch operations toolbar */}
<BatchOperationToolbar
selectedCount={selectedPromptIds.size}
allSelected={prompts.length > 0 && selectedPromptIds.size === prompts.length}
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
onDelete={handleBatchDeleteClick}
isDeleting={isBatchDeleting}
/>
{/* Timeline */}
{isLoading ? (
<div className="space-y-4">
@@ -366,6 +515,9 @@ export function PromptHistoryPage() {
prompt={prompt}
onDelete={handleDeleteClick}
actionsDisabled={isMutating}
selected={selectedPromptIds.has(prompt.id)}
onSelectChange={handleSelectPrompt}
selectionMode={selectedPromptIds.size > 0 || prompts.some(p => selectedPromptIds.has(p.id))}
/>
))}
</div>
@@ -376,7 +528,7 @@ export function PromptHistoryPage() {
</div>
{/* Insights panel */}
<div className="lg:col-span-1">
<div className="lg:col-span-1 space-y-4">
<InsightsPanel
insights={insightsData?.insights}
patterns={insightsData?.patterns}
@@ -387,6 +539,11 @@ export function PromptHistoryPage() {
isAnalyzing={isAnalyzing || insightsLoading}
className="sticky top-4"
/>
<InsightsHistoryList
insights={insightsHistoryData?.insights}
isLoading={insightsHistoryLoading}
onInsightSelect={handleInsightSelect}
/>
</div>
</div>
@@ -419,6 +576,48 @@ export function PromptHistoryPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Batch Delete Confirmation Dialog */}
<Dialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'prompts.dialog.batchDeleteTitle' })}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'prompts.dialog.batchDeleteConfirm' }, { count: selectedPromptIds.size })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setBatchDeleteDialogOpen(false);
}}
disabled={isBatchDeleting}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmBatchDelete}
disabled={isBatchDeleting}
>
{isBatchDeleting ? formatMessage({ id: 'common.actions.deleting' }, { defaultValue: 'Deleting...' }) : formatMessage({ id: 'common.actions.delete' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Insight Detail Panel Overlay */}
<InsightDetailPanelOverlay
insight={selectedInsight}
onClose={() => {
setInsightDetailOpen(false);
setSelectedInsight(null);
}}
onDelete={handleDeleteInsight}
isDeleting={isDeletingInsight}
showOverlay={true}
/>
</div>
);
}

View File

@@ -36,6 +36,7 @@ const initialState = {
// Sidebar
sidebarOpen: true,
sidebarCollapsed: false,
expandedNavGroups: ['overview', 'workflow', 'knowledge', 'issues', 'tools', 'configuration'] as string[],
// View state
currentView: 'sessions' as ViewMode,
@@ -110,6 +111,10 @@ export const useAppStore = create<AppStore>()(
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
},
setExpandedNavGroups: (groups: string[]) => {
set({ expandedNavGroups: groups }, false, 'setExpandedNavGroups');
},
// ========== View Actions ==========
setCurrentView: (view: ViewMode) => {
@@ -150,6 +155,7 @@ export const useAppStore = create<AppStore>()(
colorScheme: state.colorScheme,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups,
}),
onRehydrateStorage: () => (state) => {
// Apply theme on rehydration

View File

@@ -0,0 +1,772 @@
// ========================================
// Coordinator Store
// ========================================
// Zustand store for managing coordinator execution state and command chains
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
/**
* Execution status of a coordinator
*/
export type CoordinatorStatus = 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
/**
* Node execution status within a command chain
*/
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
/**
* Log level for coordinator logs
*/
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
/**
* Command node representing a step in the coordinator pipeline
*/
export interface CommandNode {
id: string;
name: string;
description?: string;
command: string;
status: NodeExecutionStatus;
startedAt?: string;
completedAt?: string;
result?: unknown;
error?: string;
output?: string;
parentId?: string; // For hierarchical structure
children?: CommandNode[];
}
/**
* Log entry for coordinator execution
*/
export interface CoordinatorLog {
id: string;
timestamp: string;
level: LogLevel;
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
}
/**
* Question to be answered during coordinator execution
*/
export interface CoordinatorQuestion {
id: string;
nodeId: string;
title: string;
description?: string;
type: 'text' | 'single' | 'multi' | 'yes_no';
options?: string[];
required: boolean;
answer?: string | string[];
}
/**
* Pipeline details fetched from backend
*/
export interface PipelineDetails {
id: string;
name: string;
description?: string;
nodes: CommandNode[];
totalSteps: number;
estimatedDuration?: number;
}
/**
* Coordinator state
*/
export interface CoordinatorState {
// Current execution
currentExecutionId: string | null;
status: CoordinatorStatus;
startedAt?: string;
completedAt?: string;
totalElapsedMs: number;
// Command chain
commandChain: CommandNode[];
currentNodeIndex: number;
currentNode: CommandNode | null;
// Pipeline details
pipelineDetails: PipelineDetails | null;
isPipelineLoaded: boolean;
// Logs
logs: CoordinatorLog[];
maxLogs: number;
// Interactive questions
activeQuestion: CoordinatorQuestion | null;
pendingQuestions: CoordinatorQuestion[];
// Execution metadata
metadata: Record<string, unknown>;
// Error tracking
lastError?: string;
errorDetails?: unknown;
// UI state
isLogPanelExpanded: boolean;
autoScrollLogs: boolean;
// Actions
startCoordinator: (executionId: string, taskDescription: string, parameters?: Record<string, unknown>) => Promise<void>;
pauseCoordinator: () => Promise<void>;
resumeCoordinator: () => Promise<void>;
cancelCoordinator: (reason?: string) => Promise<void>;
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => void;
submitAnswer: (questionId: string, answer: string | string[]) => Promise<void>;
retryNode: (nodeId: string) => Promise<void>;
skipNode: (nodeId: string) => Promise<void>;
fetchPipelineDetails: (executionId: string) => Promise<void>;
syncStateFromServer: () => Promise<void>;
addLog: (message: string, level?: LogLevel, nodeId?: string, source?: 'system' | 'node' | 'user') => void;
clearLogs: () => void;
setActiveQuestion: (question: CoordinatorQuestion | null) => void;
markExecutionComplete: (success: boolean, finalResult?: unknown) => void;
setLogPanelExpanded: (expanded: boolean) => void;
setAutoScrollLogs: (autoScroll: boolean) => void;
reset: () => void;
}
// ========== Constants ==========
const MAX_LOGS = 1000;
const LOG_STORAGE_KEY = 'coordinator-storage';
const COORDINATOR_STORAGE_VERSION = 1;
// ========== Helper Functions ==========
/**
* Generate unique ID for logs and questions
*/
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Find node by ID in command chain (handles hierarchical structure)
*/
const findNodeById = (nodes: CommandNode[], nodeId: string): CommandNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, nodeId);
if (found) return found;
}
}
return null;
};
// ========== Initial State ==========
const initialState: CoordinatorState = {
currentExecutionId: null,
status: 'idle',
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
maxLogs: MAX_LOGS,
activeQuestion: null,
pendingQuestions: [],
metadata: {},
isLogPanelExpanded: true,
autoScrollLogs: true,
// Actions are added in the create callback
startCoordinator: async () => {},
pauseCoordinator: async () => {},
resumeCoordinator: async () => {},
cancelCoordinator: async () => {},
updateNodeStatus: () => {},
submitAnswer: async () => {},
retryNode: async () => {},
skipNode: async () => {},
fetchPipelineDetails: async () => {},
syncStateFromServer: async () => {},
addLog: () => {},
clearLogs: () => {},
setActiveQuestion: () => {},
markExecutionComplete: () => {},
setLogPanelExpanded: () => {},
setAutoScrollLogs: () => {},
reset: () => {},
};
// ========== Store ==========
/**
* Coordinator store for managing orchestrator execution state
*
* @remarks
* Uses Zustand with persist middleware to save execution metadata to localStorage.
* The store manages command chains, logs, interactive questions, and execution status.
*
* @example
* ```tsx
* const { startCoordinator, status, logs } = useCoordinatorStore();
* await startCoordinator('exec-123', 'Build and deploy application');
* ```
*/
export const useCoordinatorStore = create<CoordinatorState>()(
persist(
devtools(
(set, get) => ({
...initialState,
// ========== Coordinator Lifecycle Actions ==========
startCoordinator: async (
executionId: string,
taskDescription: string,
parameters?: Record<string, unknown>
) => {
set({
currentExecutionId: executionId,
status: 'initializing',
startedAt: new Date().toISOString(),
totalElapsedMs: 0,
lastError: undefined,
errorDetails: undefined,
metadata: parameters || {},
}, false, 'coordinator/startCoordinator');
get().addLog(`Starting coordinator execution: ${taskDescription}`, 'info', undefined, 'system');
try {
// Fetch pipeline details from backend
await get().fetchPipelineDetails(executionId);
const state = get();
set({
status: 'running',
currentNodeIndex: 0,
currentNode: state.commandChain.length > 0 ? state.commandChain[0] : null,
}, false, 'coordinator/startCoordinator-running');
get().addLog('Coordinator running', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
status: 'failed',
lastError: errorMessage,
errorDetails: error,
}, false, 'coordinator/startCoordinator-error');
get().addLog(`Failed to start coordinator: ${errorMessage}`, 'error', undefined, 'system');
}
},
pauseCoordinator: async () => {
const state = get();
if (state.status !== 'running') {
get().addLog('Cannot pause - coordinator is not running', 'warn', undefined, 'system');
return;
}
set({ status: 'paused' }, false, 'coordinator/pauseCoordinator');
get().addLog('Coordinator paused', 'info', undefined, 'system');
},
resumeCoordinator: async () => {
const state = get();
if (state.status !== 'paused') {
get().addLog('Cannot resume - coordinator is not paused', 'warn', undefined, 'system');
return;
}
set({ status: 'running' }, false, 'coordinator/resumeCoordinator');
get().addLog('Coordinator resumed', 'info', undefined, 'system');
},
cancelCoordinator: async (reason?: string) => {
set({
status: 'cancelled',
completedAt: new Date().toISOString(),
}, false, 'coordinator/cancelCoordinator');
const message = reason ? `Coordinator cancelled: ${reason}` : 'Coordinator cancelled';
get().addLog(message, 'warn', undefined, 'system');
},
// ========== Node Status Management ==========
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
console.warn(`[CoordinatorStore] Node not found: ${nodeId}`);
return;
}
// Create a deep copy of the command chain with updated node
const updateNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
const updated: CommandNode = { ...n, status };
if (status === 'running') {
updated.startedAt = new Date().toISOString();
} else if (status === 'completed') {
updated.completedAt = new Date().toISOString();
updated.result = result;
} else if (status === 'failed') {
updated.completedAt = new Date().toISOString();
updated.error = error;
} else if (status === 'skipped') {
updated.completedAt = new Date().toISOString();
}
return updated;
}
if (n.children && n.children.length > 0) {
return { ...n, children: updateNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = updateNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/updateNodeStatus');
// Add logs after state update
if (status === 'running') {
get().addLog(`Node started: ${node.name}`, 'debug', nodeId, 'system');
} else if (status === 'completed') {
get().addLog(`Node completed: ${node.name}`, 'success', nodeId, 'system');
} else if (status === 'failed') {
get().addLog(`Node failed: ${node.name} - ${error || 'Unknown error'}`, 'error', nodeId, 'system');
} else if (status === 'skipped') {
get().addLog(`Node skipped: ${node.name}`, 'info', nodeId, 'system');
}
},
// ========== Interactive Question Handling ==========
submitAnswer: async (questionId: string, answer: string | string[]) => {
const state = get();
const question = state.activeQuestion || state.pendingQuestions.find((q) => q.id === questionId);
if (!question) {
get().addLog(`Question not found: ${questionId}`, 'warn', undefined, 'system');
return;
}
// Update question with answer
const updatedActiveQuestion =
state.activeQuestion && state.activeQuestion.id === questionId
? { ...state.activeQuestion, answer }
: state.activeQuestion;
const updatedPendingQuestions = state.pendingQuestions.map((q) =>
q.id === questionId ? { ...q, answer } : q
);
set(
{
activeQuestion: updatedActiveQuestion,
pendingQuestions: updatedPendingQuestions,
},
false,
'coordinator/submitAnswer'
);
get().addLog(
`Answer submitted for question: ${question.title}`,
'info',
question.nodeId,
'user'
);
// Clear active question
set({ activeQuestion: null }, false, 'coordinator/submitAnswer-clear');
},
// ========== Node Control Actions ==========
retryNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot retry - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Retrying node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to pending
const resetNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'pending', result: undefined, error: undefined };
}
if (n.children && n.children.length > 0) {
return { ...n, children: resetNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = resetNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/retryNode');
},
skipNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot skip - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Skipping node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to skipped
const skipNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'skipped', completedAt: new Date().toISOString() };
}
if (n.children && n.children.length > 0) {
return { ...n, children: skipNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = skipNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/skipNode');
},
// ========== Pipeline Details ==========
fetchPipelineDetails: async (executionId: string) => {
try {
get().addLog('Fetching pipeline details', 'info', undefined, 'system');
// Import API function dynamically to avoid circular deps
const { fetchCoordinatorPipeline } = await import('../lib/api');
const response = await fetchCoordinatorPipeline(executionId);
if (!response.success || !response.data) {
throw new Error('Failed to fetch pipeline details');
}
const apiData = response.data;
// Transform API response to PipelineDetails
const pipelineDetails: PipelineDetails = {
id: apiData.id,
name: apiData.name,
description: apiData.description,
nodes: apiData.nodes,
totalSteps: apiData.totalSteps,
estimatedDuration: apiData.estimatedDuration,
};
set({
pipelineDetails,
isPipelineLoaded: true,
commandChain: apiData.nodes,
status: apiData.status || get().status,
}, false, 'coordinator/fetchPipelineDetails');
// Load logs if available
if (apiData.logs && apiData.logs.length > 0) {
set({ logs: apiData.logs }, false, 'coordinator/fetchPipelineDetails-logs');
}
get().addLog('Pipeline details loaded', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
isPipelineLoaded: false,
lastError: errorMessage,
}, false, 'coordinator/fetchPipelineDetails-error');
get().addLog(`Failed to fetch pipeline details: ${errorMessage}`, 'error', undefined, 'system');
throw error;
}
},
// ========== State Synchronization (for WebSocket reconnection) ==========
syncStateFromServer: async () => {
const state = get();
// Only sync if we have an active execution
if (!state.currentExecutionId) {
get().addLog('No active execution to sync', 'debug', undefined, 'system');
return;
}
try {
get().addLog('Syncing state from server', 'info', undefined, 'system');
// Fetch current execution state from server
const { fetchExecutionState } = await import('../lib/api');
const response = await fetchExecutionState(state.currentExecutionId);
if (!response.success || !response.data) {
throw new Error('Failed to sync execution state');
}
const serverState = response.data;
// Update local state with server state
set({
status: serverState.status as CoordinatorStatus,
totalElapsedMs: serverState.elapsedMs,
}, false, 'coordinator/syncStateFromServer');
// Fetch full pipeline details if status indicates running/paused
if (serverState.status === 'running' || serverState.status === 'paused') {
await get().fetchPipelineDetails(state.currentExecutionId);
}
get().addLog('State synchronized with server', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[CoordinatorStore] Failed to sync state:', error);
get().addLog(`Failed to sync state from server: ${errorMessage}`, 'warn', undefined, 'system');
}
},
addLog: (
message: string,
level: LogLevel = 'info',
nodeId?: string,
source: 'system' | 'node' | 'user' = 'system'
) => {
const state = get();
const log: CoordinatorLog = {
id: generateId(),
timestamp: new Date().toISOString(),
level,
message,
nodeId,
source,
};
let updatedLogs = [...state.logs, log];
// Keep only the last maxLogs entries
if (updatedLogs.length > state.maxLogs) {
updatedLogs = updatedLogs.slice(-state.maxLogs);
}
set({ logs: updatedLogs }, false, 'coordinator/addLog');
},
clearLogs: () => {
set({ logs: [] }, false, 'coordinator/clearLogs');
},
// ========== Question Management ==========
setActiveQuestion: (question: CoordinatorQuestion | null) => {
const state = get();
const updatedPendingQuestions =
question && !state.pendingQuestions.find((q) => q.id === question.id)
? [...state.pendingQuestions, question]
: state.pendingQuestions;
set({
activeQuestion: question,
pendingQuestions: updatedPendingQuestions,
}, false, 'coordinator/setActiveQuestion');
},
// ========== Execution Completion ==========
markExecutionComplete: (success: boolean, finalResult?: unknown) => {
const state = get();
set({
status: success ? 'completed' : 'failed',
completedAt: new Date().toISOString(),
metadata: { ...state.metadata, finalResult },
}, false, 'coordinator/markExecutionComplete');
const message = success
? 'Coordinator execution completed successfully'
: 'Coordinator execution failed';
get().addLog(message, success ? 'success' : 'error', undefined, 'system');
},
// ========== UI State ==========
setLogPanelExpanded: (expanded: boolean) => {
set({ isLogPanelExpanded: expanded }, false, 'coordinator/setLogPanelExpanded');
},
setAutoScrollLogs: (autoScroll: boolean) => {
set({ autoScrollLogs: autoScroll }, false, 'coordinator/setAutoScrollLogs');
},
// ========== Reset ==========
reset: () => {
set({
currentExecutionId: null,
status: 'idle',
startedAt: undefined,
completedAt: undefined,
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
activeQuestion: null,
pendingQuestions: [],
metadata: {},
lastError: undefined,
errorDetails: undefined,
}, false, 'coordinator/reset');
get().addLog('Coordinator state reset', 'info', undefined, 'system');
},
}),
{ name: 'CoordinatorStore' }
),
{
name: LOG_STORAGE_KEY,
version: COORDINATOR_STORAGE_VERSION,
// Only persist metadata and basic pipeline info (not full nodes/logs)
partialize: (state) => ({
currentExecutionId: state.currentExecutionId,
status: state.status,
startedAt: state.startedAt,
completedAt: state.completedAt,
totalElapsedMs: state.totalElapsedMs,
metadata: state.metadata,
isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs,
// Only persist basic pipeline info, not full nodes
pipelineDetails: state.pipelineDetails ? {
id: state.pipelineDetails.id,
name: state.pipelineDetails.name,
description: state.pipelineDetails.description,
nodes: [], // Don't persist nodes - will be fetched from API
totalSteps: state.pipelineDetails.totalSteps,
estimatedDuration: state.pipelineDetails.estimatedDuration,
} : null,
}),
// Rehydration callback to restore state on page load
onRehydrateStorage: () => (state) => {
if (!state) return;
// Check if we have an active execution that needs hydration
const needsHydration =
state.currentExecutionId &&
(state.status === 'running' || state.status === 'paused' || state.status === 'initializing') &&
(!state.pipelineDetails || state.pipelineDetails.nodes.length === 0);
if (needsHydration && state.currentExecutionId) {
// Log restoration
state.addLog('Restoring coordinator state from localStorage', 'info', undefined, 'system');
// Fetch full pipeline details from API
state.fetchPipelineDetails(state.currentExecutionId).catch((error) => {
console.error('[CoordinatorStore] Failed to hydrate pipeline details:', error);
state.addLog('Failed to restore pipeline data - session may be incomplete', 'warn', undefined, 'system');
});
} else if (state.currentExecutionId) {
// Just log that we restored the session
state.addLog('Session state restored', 'info', undefined, 'system');
}
},
}
)
);
// ========== Helper Hooks ==========
/**
* Hook to get coordinator actions
* Useful for components that only need actions, not the full state
*/
export const useCoordinatorActions = () => {
return useCoordinatorStore((state) => ({
startCoordinator: state.startCoordinator,
pauseCoordinator: state.pauseCoordinator,
resumeCoordinator: state.resumeCoordinator,
cancelCoordinator: state.cancelCoordinator,
updateNodeStatus: state.updateNodeStatus,
submitAnswer: state.submitAnswer,
retryNode: state.retryNode,
skipNode: state.skipNode,
fetchPipelineDetails: state.fetchPipelineDetails,
syncStateFromServer: state.syncStateFromServer,
addLog: state.addLog,
clearLogs: state.clearLogs,
setActiveQuestion: state.setActiveQuestion,
markExecutionComplete: state.markExecutionComplete,
setLogPanelExpanded: state.setLogPanelExpanded,
setAutoScrollLogs: state.setAutoScrollLogs,
reset: state.reset,
}));
};
// ========== Selectors ==========
/**
* Select current execution status
*/
export const selectCoordinatorStatus = (state: CoordinatorState) => state.status;
/**
* Select current execution ID
*/
export const selectCurrentExecutionId = (state: CoordinatorState) => state.currentExecutionId;
/**
* Select all logs
*/
export const selectCoordinatorLogs = (state: CoordinatorState) => state.logs;
/**
* Select active question
*/
export const selectActiveQuestion = (state: CoordinatorState) => state.activeQuestion;
/**
* Select command chain
*/
export const selectCommandChain = (state: CoordinatorState) => state.commandChain;
/**
* Select current node
*/
export const selectCurrentNode = (state: CoordinatorState) => state.currentNode;
/**
* Select pipeline details
*/
export const selectPipelineDetails = (state: CoordinatorState) => state.pipelineDetails;
/**
* Select is pipeline loaded
*/
export const selectIsPipelineLoaded = (state: CoordinatorState) => state.isPipelineLoaded;

View File

@@ -76,6 +76,20 @@ export {
selectNodeStatus,
} from './executionStore';
// Coordinator Store
export {
useCoordinatorStore,
useCoordinatorActions,
selectCoordinatorStatus,
selectCurrentExecutionId,
selectCoordinatorLogs,
selectActiveQuestion,
selectCommandChain,
selectCurrentNode,
selectPipelineDetails,
selectIsPipelineLoaded,
} from './coordinatorStore';
// Re-export types for convenience
export type {
// App Store Types
@@ -119,6 +133,16 @@ export type {
AskQuestionPayload,
} from '../types/store';
// Coordinator Store Types
export type {
CoordinatorState,
CoordinatorStatus,
CommandNode,
CoordinatorLog,
CoordinatorQuestion,
PipelineDetails,
} from './coordinatorStore';
// Execution Types
export type {
ExecutionStatus,

View File

@@ -24,6 +24,7 @@ export interface AppState {
// Sidebar
sidebarOpen: boolean;
sidebarCollapsed: boolean;
expandedNavGroups: string[];
// View state
currentView: ViewMode;
@@ -50,6 +51,7 @@ export interface AppActions {
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
setExpandedNavGroups: (groups: string[]) => void;
// View actions
setCurrentView: (view: ViewMode) => void;
@@ -611,6 +613,8 @@ export interface Prompt {
category?: string;
/** Search tags */
tags?: string[];
/** Project path */
project?: string;
/** Usage count */
useCount?: number;
/** Last used timestamp */
@@ -619,6 +623,8 @@ export interface Prompt {
createdAt: string;
/** Updated timestamp */
updatedAt?: string;
/** Quality score (0-100) */
quality_score?: number;
}
/**

View File

@@ -126,11 +126,16 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper
// Usage: ccw cli -p "..." --tool <name> --mode analysis
try {
const projectDir = os.homedir(); // Use home dir as base for global config
// Merge user-provided tags with cli-wrapper tag for proper type registration
const userTags = request.settings.tags || [];
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
addClaudeCustomEndpoint(projectDir, {
id: endpointId,
name: request.name,
enabled: request.enabled ?? true,
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
tags,
availableModels: request.settings.availableModels,
settingsFile: request.settings.settingsFile
});
console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`);
} catch (syncError) {
@@ -306,11 +311,17 @@ export function toggleEndpointEnabled(endpointId: string, enabled: boolean): Set
// Sync enabled status with cli-tools.json tools (cli-wrapper type)
try {
const projectDir = os.homedir();
// Load full settings to get tags
const endpoint = loadEndpointSettings(endpointId);
const userTags = endpoint?.settings.tags || [];
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
addClaudeCustomEndpoint(projectDir, {
id: endpointId,
name: metadata.name,
enabled: enabled,
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
tags,
availableModels: endpoint?.settings.availableModels,
settingsFile: endpoint?.settings.settingsFile
});
console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`);
} catch (syncError) {

View File

@@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSyn
import { join, isAbsolute, extname } from 'path';
import { homedir } from 'os';
import { getMemoryStore } from '../memory-store.js';
import { getCoreMemoryStore } from '../core-memory-store.js';
import { executeCliTool } from '../../tools/cli-executor.js';
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
@@ -87,6 +88,147 @@ function calculateQualityScore(text: string): number {
export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Memory Module - Get all memories (core memory list)
if (pathname === '/api/memory' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
const memories = store.getMemories({ archived: false, limit: 100 });
// Calculate total size
const totalSize = memories.reduce((sum, m) => sum + (m.content?.length || 0), 0);
// Count CLAUDE.md files (assuming memories with source='CLAUDE.md')
const claudeMdCount = memories.filter(m => m.metadata?.includes('CLAUDE.md') || m.content?.includes('# Claude Instructions')).length;
// Transform to frontend format
const formattedMemories = memories.map(m => ({
id: m.id,
content: m.content,
createdAt: m.created_at,
updatedAt: m.updated_at,
source: m.metadata || undefined,
tags: [], // TODO: Extract tags from metadata if available
size: m.content?.length || 0
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
memories: formattedMemories,
totalSize,
claudeMdCount
}));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Memory Module - Create new memory
if (pathname === '/api/memory' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { content, tags, path: projectPath } = body;
if (!content) {
return { error: 'content is required', status: 400 };
}
const basePath = projectPath || initialPath;
try {
const store = getCoreMemoryStore(basePath);
const memory = store.upsertMemory({ content });
// Broadcast update event
broadcastToClients({
type: 'CORE_MEMORY_CREATED',
payload: {
memoryId: memory.id,
timestamp: new Date().toISOString()
}
});
return {
id: memory.id,
content: memory.content,
createdAt: memory.created_at,
updatedAt: memory.updated_at,
source: memory.metadata || undefined,
tags: tags || [],
size: memory.content?.length || 0
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Memory Module - Update memory
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'PATCH') {
const memoryId = pathname.replace('/api/memory/', '');
handlePostRequest(req, res, async (body) => {
const { content, tags, path: projectPath } = body;
const basePath = projectPath || initialPath;
try {
const store = getCoreMemoryStore(basePath);
const memory = store.upsertMemory({ id: memoryId, content });
// Broadcast update event
broadcastToClients({
type: 'CORE_MEMORY_UPDATED',
payload: {
memoryId,
timestamp: new Date().toISOString()
}
});
return {
id: memory.id,
content: memory.content,
createdAt: memory.created_at,
updatedAt: memory.updated_at,
source: memory.metadata || undefined,
tags: tags || [],
size: memory.content?.length || 0
};
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: Memory Module - Delete memory
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'DELETE') {
const memoryId = pathname.replace('/api/memory/', '');
const projectPath = url.searchParams.get('path') || initialPath;
try {
const store = getCoreMemoryStore(projectPath);
store.deleteMemory(memoryId);
// Broadcast update event
broadcastToClients({
type: 'CORE_MEMORY_DELETED',
payload: {
memoryId,
timestamp: new Date().toISOString()
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: Memory Module - Track entity access
if (pathname === '/api/memory/track' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {

View File

@@ -562,8 +562,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleClaudeRoutes(routeContext)) return;
}
// Memory routes (/api/memory/*)
if (pathname.startsWith('/api/memory/')) {
// Memory routes (/api/memory and /api/memory/*)
if (pathname === '/api/memory' || pathname.startsWith('/api/memory/')) {
if (await handleMemoryRoutes(routeContext)) return;
}

View File

@@ -429,3 +429,170 @@ export function broadcastOrchestratorLog(execId: string, log: Omit<ExecutionLog,
timestamp: new Date().toISOString()
});
}
/**
* Coordinator WebSocket message types
*/
export type CoordinatorMessageType =
| 'COORDINATOR_STATE_UPDATE'
| 'COORDINATOR_COMMAND_STARTED'
| 'COORDINATOR_COMMAND_COMPLETED'
| 'COORDINATOR_COMMAND_FAILED'
| 'COORDINATOR_LOG_ENTRY'
| 'COORDINATOR_QUESTION_ASKED'
| 'COORDINATOR_ANSWER_RECEIVED';
/**
* Coordinator State Update - fired when coordinator execution status changes
*/
export interface CoordinatorStateUpdateMessage {
type: 'COORDINATOR_STATE_UPDATE';
executionId: string;
status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
currentNodeId?: string;
timestamp: string;
}
/**
* Coordinator Command Started - fired when a command node begins execution
*/
export interface CoordinatorCommandStartedMessage {
type: 'COORDINATOR_COMMAND_STARTED';
executionId: string;
nodeId: string;
commandName: string;
timestamp: string;
}
/**
* Coordinator Command Completed - fired when a command node finishes successfully
*/
export interface CoordinatorCommandCompletedMessage {
type: 'COORDINATOR_COMMAND_COMPLETED';
executionId: string;
nodeId: string;
result?: unknown;
timestamp: string;
}
/**
* Coordinator Command Failed - fired when a command node encounters an error
*/
export interface CoordinatorCommandFailedMessage {
type: 'COORDINATOR_COMMAND_FAILED';
executionId: string;
nodeId: string;
error: string;
timestamp: string;
}
/**
* Coordinator Log Entry - fired for execution log entries
*/
export interface CoordinatorLogEntryMessage {
type: 'COORDINATOR_LOG_ENTRY';
executionId: string;
log: {
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
timestamp: string;
};
timestamp: string;
}
/**
* Coordinator Question Asked - fired when coordinator needs user input
*/
export interface CoordinatorQuestionAskedMessage {
type: 'COORDINATOR_QUESTION_ASKED';
executionId: string;
question: {
id: string;
nodeId: string;
title: string;
description?: string;
type: 'text' | 'single' | 'multi' | 'yes_no';
options?: string[];
required: boolean;
};
timestamp: string;
}
/**
* Coordinator Answer Received - fired when user submits an answer
*/
export interface CoordinatorAnswerReceivedMessage {
type: 'COORDINATOR_ANSWER_RECEIVED';
executionId: string;
questionId: string;
answer: string | string[];
timestamp: string;
}
/**
* Union type for Coordinator messages (without timestamp - added automatically)
*/
export type CoordinatorMessage =
| Omit<CoordinatorStateUpdateMessage, 'timestamp'>
| Omit<CoordinatorCommandStartedMessage, 'timestamp'>
| Omit<CoordinatorCommandCompletedMessage, 'timestamp'>
| Omit<CoordinatorCommandFailedMessage, 'timestamp'>
| Omit<CoordinatorLogEntryMessage, 'timestamp'>
| Omit<CoordinatorQuestionAskedMessage, 'timestamp'>
| Omit<CoordinatorAnswerReceivedMessage, 'timestamp'>;
/**
* Coordinator-specific broadcast with throttling
* Throttles COORDINATOR_STATE_UPDATE messages to avoid flooding clients
*/
let lastCoordinatorBroadcast = 0;
const COORDINATOR_BROADCAST_THROTTLE = 1000; // 1 second
/**
* Broadcast coordinator update with throttling
* STATE_UPDATE messages are throttled to 1 per second
* Other message types are sent immediately
*/
export function broadcastCoordinatorUpdate(message: CoordinatorMessage): void {
const now = Date.now();
// Throttle COORDINATOR_STATE_UPDATE to reduce WebSocket traffic
if (message.type === 'COORDINATOR_STATE_UPDATE' && now - lastCoordinatorBroadcast < COORDINATOR_BROADCAST_THROTTLE) {
return;
}
if (message.type === 'COORDINATOR_STATE_UPDATE') {
lastCoordinatorBroadcast = now;
}
broadcastToClients({
...message,
timestamp: new Date().toISOString()
});
}
/**
* Broadcast coordinator log entry (no throttling)
* Used for streaming real-time coordinator logs to Dashboard
*/
export function broadcastCoordinatorLog(
executionId: string,
log: {
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
}
): void {
broadcastToClients({
type: 'COORDINATOR_LOG_ENTRY',
executionId,
log: {
...log,
timestamp: new Date().toISOString()
},
timestamp: new Date().toISOString()
});
}

View File

@@ -860,7 +860,7 @@ export function removeClaudeApiEndpoint(
*/
export function addClaudeCustomEndpoint(
projectDir: string,
endpoint: { id: string; name: string; enabled: boolean; tags?: string[] }
endpoint: { id: string; name: string; enabled: boolean; tags?: string[]; availableModels?: string[]; settingsFile?: string }
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
@@ -869,7 +869,9 @@ export function addClaudeCustomEndpoint(
config.tools[endpoint.name] = {
enabled: endpoint.enabled,
tags: endpoint.tags.filter(t => t !== 'cli-wrapper'),
type: 'cli-wrapper'
type: 'cli-wrapper',
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
};
} else {
// API endpoint tool
@@ -877,7 +879,9 @@ export function addClaudeCustomEndpoint(
enabled: endpoint.enabled,
tags: [],
type: 'api-endpoint',
id: endpoint.id
id: endpoint.id,
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
};
}

View File

@@ -23,6 +23,12 @@ export interface ClaudeCliSettings {
model?: 'opus' | 'sonnet' | 'haiku' | string;
/** 是否包含 co-authored-by */
includeCoAuthoredBy?: boolean;
/** CLI工具标签 (用于标签路由) */
tags?: string[];
/** 可用模型列表 (显示在下拉菜单中) */
availableModels?: string[];
/** 外部配置文件路径 (用于 builtin claude 工具) */
settingsFile?: string;
}
/**
@@ -104,7 +110,9 @@ export function createDefaultSettings(): ClaudeCliSettings {
DISABLE_AUTOUPDATER: '1'
},
model: 'sonnet',
includeCoAuthoredBy: false
includeCoAuthoredBy: false,
tags: [],
availableModels: []
};
}
@@ -123,6 +131,18 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
return false;
}
// 深层验证env 内部所有值必须是 string 或 undefined
const envObj = s.env as Record<string, unknown>;
for (const key in envObj) {
if (Object.prototype.hasOwnProperty.call(envObj, key)) {
const value = envObj[key];
// 允许 undefined 或 string其他类型包括 null都拒绝
if (value !== undefined && typeof value !== 'string') {
return false;
}
}
}
// model 可选,但如果存在必须是字符串
if (s.model !== undefined && typeof s.model !== 'string') {
return false;
@@ -133,5 +153,20 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
return false;
}
// tags 可选,但如果存在必须是数组
if (s.tags !== undefined && !Array.isArray(s.tags)) {
return false;
}
// availableModels 可选,但如果存在必须是数组
if (s.availableModels !== undefined && !Array.isArray(s.availableModels)) {
return false;
}
// settingsFile 可选,但如果存在必须是字符串
if (s.settingsFile !== undefined && typeof s.settingsFile !== 'string') {
return false;
}
return true;
}

View File

@@ -0,0 +1,491 @@
/**
* CLI Settings Type Definitions Tests
*
* Test coverage:
* - ClaudeCliSettings interface type safety
* - validateSettings function with deep env validation
* - mapProviderToClaudeEnv helper function
* - createDefaultSettings helper function
*/
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import {
validateSettings,
mapProviderToClaudeEnv,
createDefaultSettings,
} from '../../dist/types/cli-settings.js';
// Type for testing (interfaces are erased in JS)
type ClaudeCliSettings = {
env: Record<string, string | undefined>;
model?: string;
includeCoAuthoredBy?: boolean;
tags?: string[];
availableModels?: string[];
settingsFile?: string;
};
describe('cli-settings.ts', () => {
describe('validateSettings', () => {
describe('should validate valid ClaudeCliSettings objects', () => {
it('should accept a complete valid settings object', () => {
const validSettings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
DISABLE_AUTOUPDATER: '1',
},
model: 'sonnet',
includeCoAuthoredBy: true,
tags: ['分析', 'Debug'],
availableModels: ['opus', 'sonnet', 'haiku'],
settingsFile: '/path/to/settings.json',
};
assert.strictEqual(validateSettings(validSettings), true);
});
it('should accept settings with only required env field', () => {
const minimalSettings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
},
};
assert.strictEqual(validateSettings(minimalSettings), true);
});
it('should accept settings with empty env object', () => {
const settings = {
env: {},
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept settings with undefined optional properties', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
},
};
assert.strictEqual(validateSettings(settings), true);
});
});
describe('should reject invalid or non-object inputs', () => {
it('should reject null', () => {
assert.strictEqual(validateSettings(null), false);
});
it('should reject undefined', () => {
assert.strictEqual(validateSettings(undefined), false);
});
it('should reject number', () => {
assert.strictEqual(validateSettings(123), false);
});
it('should reject string', () => {
assert.strictEqual(validateSettings('invalid'), false);
});
it('should reject boolean', () => {
assert.strictEqual(validateSettings(true), false);
});
it('should reject array', () => {
assert.strictEqual(validateSettings([]), false);
});
});
describe('should validate env field', () => {
it('should reject missing env field', () => {
const settings = {
model: 'sonnet',
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject non-object env', () => {
const settings = {
env: 'invalid',
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject env with null value', () => {
const settings = {
env: null,
};
assert.strictEqual(validateSettings(settings), false);
});
describe('deep env validation (optimization)', () => {
it('should reject env with null value (DEEP VALIDATION)', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: null,
},
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject env with number value (DEEP VALIDATION)', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 12345,
},
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject env with boolean value (DEEP VALIDATION)', () => {
const settings = {
env: {
DISABLE_AUTOUPDATER: true,
},
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject env with object value (DEEP VALIDATION)', () => {
const settings = {
env: {
CUSTOM: { nested: 'value' },
},
};
assert.strictEqual(validateSettings(settings), false);
});
it('should reject env with array value (DEEP VALIDATION)', () => {
const settings = {
env: {
TAGS: ['tag1', 'tag2'],
},
};
assert.strictEqual(validateSettings(settings), false);
});
it('should accept env with string values', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
DISABLE_AUTOUPDATER: '1',
},
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept env with undefined values (optional env vars)', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
ANTHROPIC_BASE_URL: undefined,
},
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept env with all undefined values', () => {
const settings = {
env: {
OPTIONAL_VAR: undefined,
},
};
assert.strictEqual(validateSettings(settings), true);
});
});
});
describe('should validate model field', () => {
it('should accept predefined model values', () => {
const settings = {
env: {},
model: 'opus',
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept custom model string', () => {
const settings = {
env: {},
model: 'custom-model-3.5',
};
assert.strictEqual(validateSettings(settings), true);
});
it('should reject non-string model', () => {
const settings = {
env: {},
model: 123,
};
assert.strictEqual(validateSettings(settings), false);
});
});
describe('should validate includeCoAuthoredBy field', () => {
it('should accept boolean includeCoAuthoredBy', () => {
const settings = {
env: {},
includeCoAuthoredBy: true,
};
assert.strictEqual(validateSettings(settings), true);
});
it('should reject non-boolean includeCoAuthoredBy', () => {
const settings = {
env: {},
includeCoAuthoredBy: 'true',
};
assert.strictEqual(validateSettings(settings), false);
});
});
describe('should validate tags field', () => {
it('should accept valid tags array', () => {
const settings = {
env: {},
tags: ['分析', 'Debug', 'testing'],
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept empty tags array', () => {
const settings = {
env: {},
tags: [],
};
assert.strictEqual(validateSettings(settings), true);
});
it('should reject non-array tags', () => {
const settings = {
env: {},
tags: 'invalid',
};
assert.strictEqual(validateSettings(settings), false);
});
});
describe('should validate availableModels field', () => {
it('should accept valid availableModels array', () => {
const settings = {
env: {},
availableModels: ['opus', 'sonnet', 'haiku', 'custom-model'],
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept empty availableModels array', () => {
const settings = {
env: {},
availableModels: [],
};
assert.strictEqual(validateSettings(settings), true);
});
it('should reject non-array availableModels', () => {
const settings = {
env: {},
availableModels: 'invalid',
};
assert.strictEqual(validateSettings(settings), false);
});
});
describe('should validate settingsFile field', () => {
it('should accept valid settingsFile string', () => {
const settings = {
env: {},
settingsFile: '/path/to/settings.json',
};
assert.strictEqual(validateSettings(settings), true);
});
it('should accept empty settingsFile string', () => {
const settings = {
env: {},
settingsFile: '',
};
assert.strictEqual(validateSettings(settings), true);
});
it('should reject non-string settingsFile', () => {
const settings = {
env: {},
settingsFile: 123,
};
assert.strictEqual(validateSettings(settings), false);
});
});
describe('edge cases and boundary conditions', () => {
it('should handle settings with many optional fields', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
DISABLE_AUTOUPDATER: '1',
CUSTOM_VAR: 'custom-value',
},
model: 'custom-model',
includeCoAuthoredBy: false,
tags: [],
availableModels: [],
settingsFile: '/path/to/settings.json',
};
assert.strictEqual(validateSettings(settings), true);
});
it('should handle settings with only env and one other field', () => {
const settings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
},
model: 'sonnet',
};
assert.strictEqual(validateSettings(settings), true);
});
});
});
describe('mapProviderToClaudeEnv', () => {
it('should map provider with apiKey only', () => {
const provider = { apiKey: 'sk-test-key' };
const env = mapProviderToClaudeEnv(provider);
assert.deepStrictEqual(env, {
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
DISABLE_AUTOUPDATER: '1',
});
});
it('should map provider with apiBase only', () => {
const provider = { apiBase: 'https://custom.api.com' };
const env = mapProviderToClaudeEnv(provider);
assert.deepStrictEqual(env, {
ANTHROPIC_BASE_URL: 'https://custom.api.com',
DISABLE_AUTOUPDATER: '1',
});
});
it('should map provider with both apiKey and apiBase', () => {
const provider = {
apiKey: 'sk-test-key',
apiBase: 'https://custom.api.com',
};
const env = mapProviderToClaudeEnv(provider);
assert.deepStrictEqual(env, {
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
ANTHROPIC_BASE_URL: 'https://custom.api.com',
DISABLE_AUTOUPDATER: '1',
});
});
it('should map empty provider to default DISABLE_AUTOUPDATER', () => {
const provider = {};
const env = mapProviderToClaudeEnv(provider);
assert.deepStrictEqual(env, {
DISABLE_AUTOUPDATER: '1',
});
});
it('should always set DISABLE_AUTOUPDATER to "1"', () => {
const env1 = mapProviderToClaudeEnv({ apiKey: 'key' });
const env2 = mapProviderToClaudeEnv({ apiBase: 'url' });
const env3 = mapProviderToClaudeEnv({});
assert.strictEqual(env1.DISABLE_AUTOUPDATER, '1');
assert.strictEqual(env2.DISABLE_AUTOUPDATER, '1');
assert.strictEqual(env3.DISABLE_AUTOUPDATER, '1');
});
});
describe('createDefaultSettings', () => {
it('should create valid default settings', () => {
const settings = createDefaultSettings();
assert.strictEqual(validateSettings(settings), true);
});
it('should include all default fields', () => {
const settings = createDefaultSettings();
assert.ok('env' in settings);
assert.ok('model' in settings);
assert.ok('includeCoAuthoredBy' in settings);
assert.ok('tags' in settings);
assert.ok('availableModels' in settings);
});
it('should have correct default values', () => {
const settings = createDefaultSettings();
assert.deepStrictEqual(settings.env, {
DISABLE_AUTOUPDATER: '1',
});
assert.strictEqual(settings.model, 'sonnet');
assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.deepStrictEqual(settings.tags, []);
assert.deepStrictEqual(settings.availableModels, []);
});
});
describe('TypeScript type safety', () => {
it('should enforce ClaudeCliSettings interface structure', () => {
// This test verifies TypeScript compilation catches type errors
const validSettings = {
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
},
model: 'opus',
includeCoAuthoredBy: true,
tags: ['tag1'],
availableModels: ['model1'],
settingsFile: '/path/to/file',
};
// Type assertion: all fields should be present and of correct type
assert.strictEqual(typeof validSettings.env, 'object');
assert.strictEqual(typeof validSettings.model, 'string');
assert.strictEqual(typeof validSettings.includeCoAuthoredBy, 'boolean');
assert.strictEqual(Array.isArray(validSettings.tags), true);
assert.strictEqual(Array.isArray(validSettings.availableModels), true);
assert.strictEqual(typeof validSettings.settingsFile, 'string');
});
});
});