mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-13 02:41:50 +08:00
feat: Add CodexLens Manager Page with tabbed interface for managing CodexLens features
feat: Implement ConflictTab component to display conflict resolution decisions in session detail feat: Create ImplPlanTab component to show implementation plan with modal viewer in session detail feat: Develop ReviewTab component to display review findings by dimension in session detail test: Add end-to-end tests for CodexLens Manager functionality including navigation, tab switching, and settings validation
This commit is contained in:
292
ccw/frontend/src/components/codexlens/AdvancedTab.tsx
Normal file
292
ccw/frontend/src/components/codexlens/AdvancedTab.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
// ========================================
|
||||
// CodexLens Advanced Tab
|
||||
// ========================================
|
||||
// Advanced settings including .env editor and ignore patterns
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Save, RefreshCw, AlertTriangle, FileCode } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useCodexLensEnv, useUpdateCodexLensEnv } from '@/hooks';
|
||||
import { useNotifications } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AdvancedTabProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
env?: string;
|
||||
}
|
||||
|
||||
export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
|
||||
const {
|
||||
raw,
|
||||
env,
|
||||
settings,
|
||||
isLoading: isLoadingEnv,
|
||||
refetch,
|
||||
} = useCodexLensEnv({ enabled });
|
||||
|
||||
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
|
||||
|
||||
// Form state
|
||||
const [envInput, setEnvInput] = useState('');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
// Initialize form from env
|
||||
useEffect(() => {
|
||||
if (raw !== undefined) {
|
||||
setEnvInput(raw);
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [raw]);
|
||||
|
||||
const handleEnvChange = (value: string) => {
|
||||
setEnvInput(value);
|
||||
// Check if there are changes
|
||||
if (raw !== undefined) {
|
||||
setHasChanges(value !== raw);
|
||||
setShowWarning(value !== raw);
|
||||
}
|
||||
if (errors.env) {
|
||||
setErrors((prev) => ({ ...prev, env: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const parseEnvVariables = (text: string): Record<string, string> => {
|
||||
const envObj: Record<string, string> = {};
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
|
||||
const [key, ...valParts] = trimmed.split('=');
|
||||
const val = valParts.join('=');
|
||||
if (key) {
|
||||
envObj[key.trim()] = val.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return envObj;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
const parsed = parseEnvVariables(envInput);
|
||||
|
||||
// Check for invalid variable names
|
||||
const invalidKeys = Object.keys(parsed).filter(
|
||||
(key) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
newErrors.env = formatMessage(
|
||||
{ id: 'codexlens.advanced.validation.invalidKeys' },
|
||||
{ keys: invalidKeys.join(', ') }
|
||||
);
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseEnvVariables(envInput);
|
||||
const result = await updateEnv({ env: parsed });
|
||||
|
||||
if (result.success) {
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.advanced.saveSuccess' }),
|
||||
result.message || formatMessage({ id: 'codexlens.advanced.envUpdated' })
|
||||
);
|
||||
refetch();
|
||||
setShowWarning(false);
|
||||
} else {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
|
||||
result.message || formatMessage({ id: 'codexlens.advanced.saveError' })
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.advanced.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (raw !== undefined) {
|
||||
setEnvInput(raw);
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
setShowWarning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingEnv;
|
||||
|
||||
// Get current env variables as array for display
|
||||
const currentEnvVars = env
|
||||
? Object.entries(env).map(([key, value]) => ({ key, value }))
|
||||
: [];
|
||||
|
||||
// Get settings variables
|
||||
const settingsVars = settings
|
||||
? Object.entries(settings).map(([key, value]) => ({ key, value }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sensitivity Warning Card */}
|
||||
{showWarning && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-warning-foreground">
|
||||
{formatMessage({ id: 'codexlens.advanced.warningTitle' })}
|
||||
</h4>
|
||||
<p className="text-xs text-warning-foreground/80 mt-1">
|
||||
{formatMessage({ id: 'codexlens.advanced.warningMessage' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Variables Summary */}
|
||||
{(currentEnvVars.length > 0 || settingsVars.length > 0) && (
|
||||
<Card className="p-4 bg-muted/30">
|
||||
<h4 className="text-sm font-medium text-foreground mb-3">
|
||||
{formatMessage({ id: 'codexlens.advanced.currentVars' })}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{settingsVars.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.advanced.settingsVars' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{settingsVars.map(({ key }) => (
|
||||
<Badge key={key} variant="outline" className="font-mono text-xs">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentEnvVars.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.advanced.customVars' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentEnvVars.map(({ key }) => (
|
||||
<Badge key={key} variant="secondary" className="font-mono text-xs">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Environment Variables Editor */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-5 h-5 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'codexlens.advanced.envEditor' })}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage({ id: 'codexlens.advanced.envFile' })}: .env
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Env Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="env-input">
|
||||
{formatMessage({ id: 'codexlens.advanced.envContent' })}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="env-input"
|
||||
value={envInput}
|
||||
onChange={(e) => handleEnvChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'codexlens.advanced.envPlaceholder' })}
|
||||
className={cn(
|
||||
'min-h-[300px] font-mono text-sm',
|
||||
errors.env && 'border-destructive focus-visible:ring-destructive'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.env && (
|
||||
<p className="text-sm text-destructive">{errors.env}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.advanced.envHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isUpdating || !hasChanges}
|
||||
>
|
||||
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
|
||||
{isUpdating
|
||||
? formatMessage({ id: 'codexlens.advanced.saving' })
|
||||
: formatMessage({ id: 'codexlens.advanced.save' })
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'codexlens.advanced.reset' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Help Card */}
|
||||
<Card className="p-4 bg-info/10 border-info/20">
|
||||
<h4 className="text-sm font-medium text-info-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.advanced.helpTitle' })}
|
||||
</h4>
|
||||
<ul className="text-xs text-info-foreground/80 space-y-1">
|
||||
<li>• {formatMessage({ id: 'codexlens.advanced.helpComment' })}</li>
|
||||
<li>• {formatMessage({ id: 'codexlens.advanced.helpFormat' })}</li>
|
||||
<li>• {formatMessage({ id: 'codexlens.advanced.helpQuotes' })}</li>
|
||||
<li>• {formatMessage({ id: 'codexlens.advanced.helpRestart' })}</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdvancedTab;
|
||||
293
ccw/frontend/src/components/codexlens/GpuSelector.tsx
Normal file
293
ccw/frontend/src/components/codexlens/GpuSelector.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
// ========================================
|
||||
// CodexLens GPU Selector
|
||||
// ========================================
|
||||
// GPU detection, listing, and selection component
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Cpu, Search, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useCodexLensGpu, useSelectGpu } from '@/hooks';
|
||||
import { useNotifications } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodexLensGpuDevice } from '@/lib/api';
|
||||
|
||||
interface GpuSelectorProps {
|
||||
enabled?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function GpuSelector({ enabled = true, compact = false }: GpuSelectorProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
|
||||
const {
|
||||
supported,
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
isLoadingDetect,
|
||||
isLoadingList,
|
||||
refetch,
|
||||
} = useCodexLensGpu({ enabled });
|
||||
|
||||
const { selectGpu, resetGpu, isSelecting, isResetting } = useSelectGpu();
|
||||
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
|
||||
const isLoading = isLoadingDetect || isLoadingList || isDetecting;
|
||||
|
||||
const handleDetect = async () => {
|
||||
setIsDetecting(true);
|
||||
try {
|
||||
await refetch();
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.gpu.detectSuccess' }),
|
||||
formatMessage({ id: 'codexlens.gpu.detectComplete' }, { count: devices?.length ?? 0 })
|
||||
);
|
||||
} catch (err) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.gpu.detectFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.detectError' })
|
||||
);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (deviceId: string | number) => {
|
||||
try {
|
||||
const result = await selectGpu(deviceId);
|
||||
if (result.success) {
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.gpu.selectSuccess' }),
|
||||
result.message || formatMessage({ id: 'codexlens.gpu.gpuSelected' })
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
|
||||
result.message || formatMessage({ id: 'codexlens.gpu.selectError' })
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
const result = await resetGpu();
|
||||
if (result.success) {
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.gpu.resetSuccess' }),
|
||||
result.message || formatMessage({ id: 'codexlens.gpu.gpuReset' })
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
|
||||
result.message || formatMessage({ id: 'codexlens.gpu.resetError' })
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.gpu.status' })}:
|
||||
</span>
|
||||
{supported !== false ? (
|
||||
selectedDeviceId !== undefined ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{formatMessage({ id: 'codexlens.gpu.enabled' })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage({ id: 'codexlens.gpu.available' })}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: 'codexlens.gpu.unavailable' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetect}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Search className={cn('w-3 h-3 mr-1', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'codexlens.gpu.detect' })}
|
||||
</Button>
|
||||
{selectedDeviceId !== undefined && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'codexlens.gpu.reset' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header Card */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
supported !== false ? 'bg-success/10' : 'bg-secondary'
|
||||
)}>
|
||||
<Cpu className={cn(
|
||||
'w-5 h-5',
|
||||
supported !== false ? 'text-success' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'codexlens.gpu.title' })}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{supported !== false
|
||||
? formatMessage({ id: 'codexlens.gpu.supported' })
|
||||
: formatMessage({ id: 'codexlens.gpu.notSupported' })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetect}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'codexlens.gpu.detect' })}
|
||||
</Button>
|
||||
{selectedDeviceId !== undefined && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isResetting && 'animate-spin')} />
|
||||
{formatMessage({ id: 'codexlens.gpu.reset' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Device List */}
|
||||
{devices && devices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => {
|
||||
const deviceId = device.device_id ?? device.index;
|
||||
return (
|
||||
<DeviceCard
|
||||
key={deviceId}
|
||||
device={device}
|
||||
isSelected={deviceId === selectedDeviceId}
|
||||
onSelect={() => handleSelect(deviceId)}
|
||||
isSelecting={isSelecting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-8 text-center">
|
||||
<Cpu className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{supported !== false
|
||||
? formatMessage({ id: 'codexlens.gpu.noDevices' })
|
||||
: formatMessage({ id: 'codexlens.gpu.notAvailable' })
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: CodexLensGpuDevice;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
isSelecting: boolean;
|
||||
}
|
||||
|
||||
function DeviceCard({ device, isSelected, onSelect, isSelecting }: DeviceCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
'p-4 transition-colors',
|
||||
isSelected && 'border-primary bg-primary/5'
|
||||
)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="text-sm font-medium text-foreground">
|
||||
{device.name || formatMessage({ id: 'codexlens.gpu.unknownDevice' })}
|
||||
</h5>
|
||||
{isSelected && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'codexlens.gpu.selected' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.gpu.type' })}: {device.type === 'discrete' ? '独立显卡' : '集成显卡'}
|
||||
</p>
|
||||
{device.memory?.total && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.gpu.memory' })}: {(device.memory.total / 1024).toFixed(1)} GB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={isSelected ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={onSelect}
|
||||
disabled={isSelected || isSelecting}
|
||||
>
|
||||
{isSelected
|
||||
? formatMessage({ id: 'codexlens.gpu.active' })
|
||||
: formatMessage({ id: 'codexlens.gpu.select' })
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default GpuSelector;
|
||||
231
ccw/frontend/src/components/codexlens/ModelCard.tsx
Normal file
231
ccw/frontend/src/components/codexlens/ModelCard.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// ========================================
|
||||
// Model Card Component
|
||||
// ========================================
|
||||
// Individual model display card with actions
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
Package,
|
||||
HardDrive,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { CodexLensModel } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ModelCardProps {
|
||||
model: CodexLensModel;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
isDeleting?: boolean;
|
||||
onDownload: (profile: string) => void;
|
||||
onDelete: (profile: string) => void;
|
||||
onCancelDownload?: () => void;
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getModelTypeVariant(type: 'embedding' | 'reranker'): 'default' | 'secondary' {
|
||||
return type === 'embedding' ? 'default' : 'secondary';
|
||||
}
|
||||
|
||||
function formatSize(size?: string): string {
|
||||
if (!size) return '-';
|
||||
return size;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function ModelCard({
|
||||
model,
|
||||
isDownloading = false,
|
||||
downloadProgress = 0,
|
||||
isDeleting = false,
|
||||
onDownload,
|
||||
onDelete,
|
||||
onCancelDownload,
|
||||
}: ModelCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleDownload = () => {
|
||||
onDownload(model.profile);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(formatMessage({ id: 'codexlens.models.deleteConfirm' }, { modelName: model.name }))) {
|
||||
onDelete(model.profile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !model.installed && 'opacity-80')}>
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg flex-shrink-0',
|
||||
model.installed ? 'bg-success/10' : 'bg-muted'
|
||||
)}>
|
||||
{model.installed ? (
|
||||
<HardDrive className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{model.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getModelTypeVariant(model.type)}
|
||||
className="text-xs flex-shrink-0"
|
||||
>
|
||||
{model.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={model.installed ? 'success' : 'outline'}
|
||||
className="text-xs flex-shrink-0"
|
||||
>
|
||||
{model.installed
|
||||
? formatMessage({ id: 'codexlens.models.status.downloaded' })
|
||||
: formatMessage({ id: 'codexlens.models.status.available' })
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>Backend: {model.backend}</span>
|
||||
<span>Size: {formatSize(model.size)}</span>
|
||||
</div>
|
||||
{model.cache_path && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{model.cache_path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isDownloading ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onCancelDownload}
|
||||
title={formatMessage({ id: 'codexlens.models.actions.cancel' })}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
) : model.installed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
title={formatMessage({ id: 'codexlens.models.actions.delete' })}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={handleDownload}
|
||||
title={formatMessage({ id: 'codexlens.models.actions.download' })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
<span className="text-xs">{formatMessage({ id: 'codexlens.models.actions.download' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Progress */}
|
||||
{isDownloading && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.downloading' })}
|
||||
</span>
|
||||
<span className="font-medium">{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Custom Model Input ==========
|
||||
|
||||
export interface CustomModelInputProps {
|
||||
isDownloading: boolean;
|
||||
onDownload: (modelName: string, modelType: 'embedding' | 'reranker') => void;
|
||||
}
|
||||
|
||||
export function CustomModelInput({ isDownloading, onDownload }: CustomModelInputProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [modelType, setModelType] = useState<'embedding' | 'reranker'>('embedding');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (modelName.trim()) {
|
||||
onDownload(modelName.trim(), modelType);
|
||||
setModelName('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 bg-primary/5 border-primary/20">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
{formatMessage({ id: 'codexlens.models.custom.title' })}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'codexlens.models.custom.placeholder' })}
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
disabled={isDownloading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<select
|
||||
value={modelType}
|
||||
onChange={(e) => setModelType(e.target.value as 'embedding' | 'reranker')}
|
||||
disabled={isDownloading}
|
||||
className="px-3 py-2 text-sm rounded-md border border-input bg-background"
|
||||
>
|
||||
<option value="embedding">{formatMessage({ id: 'codexlens.models.types.embedding' })}</option>
|
||||
<option value="reranker">{formatMessage({ id: 'codexlens.models.types.reranker' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.custom.description' })}
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelCard;
|
||||
396
ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
Normal file
396
ccw/frontend/src/components/codexlens/ModelsTab.test.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
// ========================================
|
||||
// Models Tab Component Tests
|
||||
// ========================================
|
||||
// Tests for CodexLens Models Tab component
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ModelsTab } from './ModelsTab';
|
||||
import type { CodexLensModel } from '@/lib/api';
|
||||
|
||||
// Mock hooks - use importOriginal to preserve all exports
|
||||
vi.mock('@/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/hooks')>();
|
||||
return {
|
||||
...actual,
|
||||
useCodexLensModels: vi.fn(),
|
||||
useCodexLensMutations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
|
||||
|
||||
const mockModels: CodexLensModel[] = [
|
||||
{
|
||||
profile: 'embedding1',
|
||||
name: 'BAAI/bge-small-en-v1.5',
|
||||
type: 'embedding',
|
||||
backend: 'onnx',
|
||||
installed: true,
|
||||
cache_path: '/cache/embedding1',
|
||||
},
|
||||
{
|
||||
profile: 'reranker1',
|
||||
name: 'BAAI/bge-reranker-v2-m3',
|
||||
type: 'reranker',
|
||||
backend: 'onnx',
|
||||
installed: false,
|
||||
cache_path: '/cache/reranker1',
|
||||
},
|
||||
{
|
||||
profile: 'embedding2',
|
||||
name: 'sentence-transformers/all-MiniLM-L6-v2',
|
||||
type: 'embedding',
|
||||
backend: 'torch',
|
||||
installed: false,
|
||||
cache_path: '/cache/embedding2',
|
||||
},
|
||||
];
|
||||
|
||||
const mockMutations = {
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdatingConfig: false,
|
||||
bootstrap: vi.fn().mockResolvedValue({ success: true }),
|
||||
isBootstrapping: false,
|
||||
uninstall: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUninstalling: false,
|
||||
downloadModel: vi.fn().mockResolvedValue({ success: true }),
|
||||
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }),
|
||||
isDownloading: false,
|
||||
deleteModel: vi.fn().mockResolvedValue({ success: true }),
|
||||
isDeleting: false,
|
||||
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }),
|
||||
isUpdatingEnv: false,
|
||||
selectGpu: vi.fn().mockResolvedValue({ success: true }),
|
||||
resetGpu: vi.fn().mockResolvedValue({ success: true }),
|
||||
isSelectingGpu: false,
|
||||
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }),
|
||||
isUpdatingPatterns: false,
|
||||
isMutating: false,
|
||||
};
|
||||
|
||||
describe('ModelsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should render search input', () => {
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByPlaceholderText(/Search models/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render filter buttons with counts', () => {
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByText(/All/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Embedding Models/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Reranker Models/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Downloaded/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render model list', () => {
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
|
||||
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
|
||||
expect(screen.getByText('sentence-transformers/all-MiniLM-L6-v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter models by search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search models/i);
|
||||
await user.type(searchInput, 'bge');
|
||||
|
||||
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
|
||||
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('sentence-transformers/all-MiniLM-L6-v2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by embedding type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const embeddingButton = screen.getByText(/Embedding Models/i);
|
||||
await user.click(embeddingButton);
|
||||
|
||||
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by reranker type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const rerankerButton = screen.getByText(/Reranker Models/i);
|
||||
await user.click(rerankerButton);
|
||||
|
||||
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by downloaded status', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const downloadedButton = screen.getByText(/Downloaded/i);
|
||||
await user.click(downloadedButton);
|
||||
|
||||
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
|
||||
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter by available status', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const availableButton = screen.getByText(/Available/i);
|
||||
await user.click(availableButton);
|
||||
|
||||
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call downloadModel when download clicked', async () => {
|
||||
const downloadModel = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
downloadModel,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
// Filter to show available models
|
||||
const availableButton = screen.getByText(/Available/i);
|
||||
await user.click(availableButton);
|
||||
|
||||
const downloadButton = screen.getAllByText(/Download/i)[0];
|
||||
await user.click(downloadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh models on refresh button click', async () => {
|
||||
const refetch = vi.fn();
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: undefined,
|
||||
embeddingModels: undefined,
|
||||
rerankerModels: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should show not installed message', () => {
|
||||
render(<ModelsTab installed={false} />);
|
||||
|
||||
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Please install CodexLens to use model management features/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading state', () => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: undefined,
|
||||
embeddingModels: undefined,
|
||||
rerankerModels: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('should show empty state when no models', () => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: [],
|
||||
embeddingModels: [],
|
||||
rerankerModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Try adjusting your search or filter criteria/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when search returns no results', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search models/i);
|
||||
await user.type(searchInput, 'nonexistent-model');
|
||||
|
||||
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n - Chinese locale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should display translated text', () => {
|
||||
render(<ModelsTab installed={true} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByPlaceholderText(/搜索模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/筛选/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/全部/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/嵌入模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/重排序模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/已下载/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/可用/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/刷新/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate empty state', () => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: [],
|
||||
embeddingModels: [],
|
||||
rerankerModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={true} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/没有找到模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/尝试调整搜索或筛选条件/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate not installed state', () => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: undefined,
|
||||
embeddingModels: undefined,
|
||||
rerankerModels: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={false} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/CodexLens 未安装/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom model input', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should render custom model input section', () => {
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
expect(screen.getByText(/Custom Model/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate custom model section in Chinese', () => {
|
||||
render(<ModelsTab installed={true} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/自定义模型/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle API errors gracefully', () => {
|
||||
vi.mocked(useCodexLensModels).mockReturnValue({
|
||||
models: mockModels,
|
||||
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
|
||||
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
|
||||
isLoading: false,
|
||||
error: new Error('API Error'),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<ModelsTab installed={true} />);
|
||||
|
||||
// Component should still render despite error
|
||||
expect(screen.getByText(/BAAI\/bge-small-en-v1.5/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
283
ccw/frontend/src/components/codexlens/ModelsTab.tsx
Normal file
283
ccw/frontend/src/components/codexlens/ModelsTab.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
// ========================================
|
||||
// Models Tab Component
|
||||
// ========================================
|
||||
// Model management tab with list, search, and download actions
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Package,
|
||||
Filter,
|
||||
} 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 { ModelCard, CustomModelInput } from './ModelCard';
|
||||
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
|
||||
import type { CodexLensModel } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
type FilterType = 'all' | 'embedding' | 'reranker' | 'downloaded' | 'available';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function filterModels(models: CodexLensModel[], filter: FilterType, search: string): CodexLensModel[] {
|
||||
let filtered = models;
|
||||
|
||||
// Apply type/status filter
|
||||
if (filter === 'embedding') {
|
||||
filtered = filtered.filter(m => m.type === 'embedding');
|
||||
} else if (filter === 'reranker') {
|
||||
filtered = filtered.filter(m => m.type === 'reranker');
|
||||
} else if (filter === 'downloaded') {
|
||||
filtered = filtered.filter(m => m.installed);
|
||||
} else if (filter === 'available') {
|
||||
filtered = filtered.filter(m => !m.installed);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (search.trim()) {
|
||||
const query = search.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.profile.toLowerCase().includes(query) ||
|
||||
m.backend.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface ModelsTabProps {
|
||||
installed?: boolean;
|
||||
}
|
||||
|
||||
export function ModelsTab({ installed = false }: ModelsTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [downloadingProfile, setDownloadingProfile] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
|
||||
const {
|
||||
models,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useCodexLensModels({
|
||||
enabled: installed,
|
||||
});
|
||||
|
||||
const {
|
||||
downloadModel,
|
||||
downloadCustomModel,
|
||||
deleteModel,
|
||||
isDownloading,
|
||||
isDeleting,
|
||||
} = useCodexLensMutations();
|
||||
|
||||
// Filter models based on search and filter
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!models) return [];
|
||||
return filterModels(models, filterType, searchQuery);
|
||||
}, [models, filterType, searchQuery]);
|
||||
|
||||
// Count models by type and status
|
||||
const stats = useMemo(() => {
|
||||
if (!models) return null;
|
||||
return {
|
||||
total: models.length,
|
||||
embedding: models.filter(m => m.type === 'embedding').length,
|
||||
reranker: models.filter(m => m.type === 'reranker').length,
|
||||
downloaded: models.filter(m => m.installed).length,
|
||||
available: models.filter(m => !m.installed).length,
|
||||
};
|
||||
}, [models]);
|
||||
|
||||
// Handle model download
|
||||
const handleDownload = async (profile: string) => {
|
||||
setDownloadingProfile(profile);
|
||||
setDownloadProgress(0);
|
||||
|
||||
// Simulate progress for demo (in real implementation, use WebSocket or polling)
|
||||
const progressInterval = setInterval(() => {
|
||||
setDownloadProgress(prev => {
|
||||
if (prev >= 95) {
|
||||
clearInterval(progressInterval);
|
||||
return 95;
|
||||
}
|
||||
return prev + 5;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const result = await downloadModel(profile);
|
||||
if (result.success) {
|
||||
setDownloadProgress(100);
|
||||
setTimeout(() => {
|
||||
setDownloadingProfile(null);
|
||||
setDownloadProgress(0);
|
||||
refetch();
|
||||
}, 500);
|
||||
} else {
|
||||
setDownloadingProfile(null);
|
||||
setDownloadProgress(0);
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadingProfile(null);
|
||||
setDownloadProgress(0);
|
||||
} finally {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle custom model download
|
||||
const handleCustomDownload = async (modelName: string, modelType: 'embedding' | 'reranker') => {
|
||||
try {
|
||||
const result = await downloadCustomModel(modelName, modelType);
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download custom model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle model delete
|
||||
const handleDelete = async (profile: string) => {
|
||||
const result = await deleteModel(profile);
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// Filter buttons
|
||||
const filterButtons: Array<{ type: FilterType; label: string; count: number | undefined }> = [
|
||||
{ type: 'all', label: formatMessage({ id: 'codexlens.models.filters.all' }), count: stats?.total },
|
||||
{ type: 'embedding', label: formatMessage({ id: 'codexlens.models.types.embedding' }), count: stats?.embedding },
|
||||
{ type: 'reranker', label: formatMessage({ id: 'codexlens.models.types.reranker' }), count: stats?.reranker },
|
||||
{ type: 'downloaded', label: formatMessage({ id: 'codexlens.models.status.downloaded' }), count: stats?.downloaded },
|
||||
{ type: 'available', label: formatMessage({ id: 'codexlens.models.status.available' }), count: stats?.available },
|
||||
];
|
||||
|
||||
if (!installed) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<Package className="w-16 h-16 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.models.notInstalled.title' })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.notInstalled.description' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with Search and Actions */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'codexlens.models.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-1', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats and Filters */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.filters.label' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filterButtons.map(({ type, label, count }) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filterType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(type)}
|
||||
className="relative"
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<Badge variant={filterType === type ? 'secondary' : 'default'} className="ml-2">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Custom Model Input */}
|
||||
<CustomModelInput
|
||||
isDownloading={isDownloading}
|
||||
onDownload={handleCustomDownload}
|
||||
/>
|
||||
|
||||
{/* Model List */}
|
||||
{isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
|
||||
</Card>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'codexlens.models.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.empty.description' })}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.profile}
|
||||
model={model}
|
||||
isDownloading={downloadingProfile === model.profile}
|
||||
downloadProgress={downloadProgress}
|
||||
isDeleting={isDeleting && downloadingProfile !== model.profile}
|
||||
onDownload={handleDownload}
|
||||
onDelete={handleDelete}
|
||||
onCancelDownload={() => {
|
||||
setDownloadingProfile(null);
|
||||
setDownloadProgress(0);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelsTab;
|
||||
280
ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
Normal file
280
ccw/frontend/src/components/codexlens/OverviewTab.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
// ========================================
|
||||
// Overview Tab Component Tests
|
||||
// ========================================
|
||||
// Tests for CodexLens Overview Tab component
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
|
||||
|
||||
const mockStatus: CodexLensVenvStatus = {
|
||||
ready: true,
|
||||
installed: true,
|
||||
version: '1.0.0',
|
||||
pythonVersion: '3.11.0',
|
||||
venvPath: '/path/to/venv',
|
||||
};
|
||||
|
||||
const mockConfig: CodexLensConfig = {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
};
|
||||
|
||||
// Mock window.alert
|
||||
global.alert = vi.fn();
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when installed and ready', () => {
|
||||
const defaultProps = {
|
||||
installed: true,
|
||||
status: mockStatus,
|
||||
config: mockConfig,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
it('should render status cards', () => {
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Version/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.0.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render index path with full path in title', () => {
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
const indexPath = screen.getByText(/Index Path/i).nextElementSibling as HTMLElement;
|
||||
expect(indexPath).toHaveTextContent('~/.codexlens/indexes');
|
||||
expect(indexPath).toHaveAttribute('title', '~/.codexlens/indexes');
|
||||
});
|
||||
|
||||
it('should render index count', () => {
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Index Count/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render quick actions section', () => {
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Quick Actions/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/FTS Full/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/FTS Incremental/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Vector Full/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Vector Incremental/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render venv details section', () => {
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Python Virtual Environment Details/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Python Version/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3.11.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show coming soon alert when action clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OverviewTab {...defaultProps} />);
|
||||
|
||||
const ftsFullButton = screen.getByText(/FTS Full/i);
|
||||
await user.click(ftsFullButton);
|
||||
|
||||
expect(global.alert).toHaveBeenCalledWith(expect.stringContaining('Coming Soon'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when installed but not ready', () => {
|
||||
const notReadyProps = {
|
||||
installed: true,
|
||||
status: { ...mockStatus, ready: false },
|
||||
config: mockConfig,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
it('should show not ready status', () => {
|
||||
render(<OverviewTab {...notReadyProps} />);
|
||||
|
||||
expect(screen.getByText(/Not Ready/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable action buttons when not ready', () => {
|
||||
render(<OverviewTab {...notReadyProps} />);
|
||||
|
||||
const ftsFullButton = screen.getByText(/FTS Full/i).closest('button');
|
||||
expect(ftsFullButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not installed', () => {
|
||||
const notInstalledProps = {
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
it('should show not installed message', () => {
|
||||
render(<OverviewTab {...notInstalledProps} />);
|
||||
|
||||
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Please install CodexLens to use semantic code search features/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should show loading skeleton', () => {
|
||||
const { container } = render(
|
||||
<OverviewTab
|
||||
installed={false}
|
||||
status={undefined}
|
||||
config={undefined}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for pulse/skeleton elements
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n - Chinese locale', () => {
|
||||
const defaultProps = {
|
||||
installed: true,
|
||||
status: mockStatus,
|
||||
config: mockConfig,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
it('should display translated text in Chinese', () => {
|
||||
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/安装状态/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/就绪/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/版本/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/索引路径/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/索引数量/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/快速操作/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Python 虚拟环境详情/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate action buttons', () => {
|
||||
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/FTS 全量/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/FTS 增量/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/向量全量/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/向量增量/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status card colors', () => {
|
||||
it('should show success color when ready', () => {
|
||||
const { container } = render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={mockStatus}
|
||||
config={mockConfig}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for success/ready indication (check icon or success color)
|
||||
const statusCard = container.querySelector('.bg-success\\/10');
|
||||
expect(statusCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show warning color when not ready', () => {
|
||||
const { container } = render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={{ ...mockStatus, ready: false }}
|
||||
config={mockConfig}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for warning/not ready indication
|
||||
const statusCard = container.querySelector('.bg-warning\\/10');
|
||||
expect(statusCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing status gracefully', () => {
|
||||
render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={undefined}
|
||||
config={mockConfig}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not crash and render available data
|
||||
expect(screen.getByText(/Version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing config gracefully', () => {
|
||||
render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={mockStatus}
|
||||
config={undefined}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not crash and render available data
|
||||
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty index path', () => {
|
||||
const emptyConfig: CodexLensConfig = {
|
||||
index_dir: '',
|
||||
index_count: 0,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
};
|
||||
|
||||
render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={mockStatus}
|
||||
config={emptyConfig}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Index Path/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle unknown version', () => {
|
||||
const unknownVersionStatus: CodexLensVenvStatus = {
|
||||
...mockStatus,
|
||||
version: '',
|
||||
};
|
||||
|
||||
render(
|
||||
<OverviewTab
|
||||
installed={true}
|
||||
status={unknownVersionStatus}
|
||||
config={mockConfig}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Version/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
246
ccw/frontend/src/components/codexlens/OverviewTab.tsx
Normal file
246
ccw/frontend/src/components/codexlens/OverviewTab.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// ========================================
|
||||
// CodexLens Overview Tab
|
||||
// ========================================
|
||||
// Overview status display and quick actions for CodexLens
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RotateCw,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
|
||||
|
||||
interface OverviewTabProps {
|
||||
installed: boolean;
|
||||
status?: CodexLensVenvStatus;
|
||||
config?: CodexLensConfig;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({ installed, status, config, isLoading }: OverviewTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-20 mb-2" />
|
||||
<div className="h-8 bg-muted rounded w-16" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!installed) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Database className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.overview.notInstalled.title' })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.notInstalled.message' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isReady = status?.ready ?? false;
|
||||
const version = status?.version ?? 'Unknown';
|
||||
const indexDir = config?.index_dir ?? '~/.codexlens/indexes';
|
||||
const indexCount = config?.index_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Installation Status */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
isReady ? 'bg-success/10' : 'bg-warning/10'
|
||||
)}>
|
||||
{isReady ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-success" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-warning" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.status.installation' })}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{isReady
|
||||
? formatMessage({ id: 'codexlens.overview.status.ready' })
|
||||
: formatMessage({ id: 'codexlens.overview.status.notReady' })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Version */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.status.version' })}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-foreground">{version}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Index Path */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-info/10">
|
||||
<FileText className="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.status.indexPath' })}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground truncate" title={indexDir}>
|
||||
{indexDir}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Index Count */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/10">
|
||||
<Zap className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.status.indexCount' })}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-foreground">{indexCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.actions.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Venv Details */}
|
||||
{status && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.venv.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.venv.pythonVersion' })}
|
||||
</span>
|
||||
<span className="text-foreground font-mono">{status.pythonVersion || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.overview.venv.venvPath' })}
|
||||
</span>
|
||||
<span className="text-foreground font-mono truncate ml-4" title={status.venvPath}>
|
||||
{status.venvPath || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function QuickActionButton({ icon, label, description, disabled }: QuickActionButtonProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleClick = () => {
|
||||
// TODO: Implement index operations in future tasks
|
||||
// For now, show a message that this feature is coming soon
|
||||
alert(formatMessage({ id: 'codexlens.comingSoon' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className={cn('text-muted-foreground', disabled && 'opacity-50')}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
456
ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
Normal file
456
ccw/frontend/src/components/codexlens/SettingsTab.test.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
// ========================================
|
||||
// Settings Tab Component Tests
|
||||
// ========================================
|
||||
// Tests for CodexLens Settings Tab component with form validation
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SettingsTab } from './SettingsTab';
|
||||
import type { CodexLensConfig } from '@/lib/api';
|
||||
|
||||
// Mock hooks - use importOriginal to preserve all exports
|
||||
vi.mock('@/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/hooks')>();
|
||||
return {
|
||||
...actual,
|
||||
useCodexLensConfig: vi.fn(),
|
||||
useUpdateCodexLensConfig: vi.fn(),
|
||||
useNotifications: vi.fn(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
toasts: [],
|
||||
wsStatus: 'disconnected' as const,
|
||||
wsLastMessage: null,
|
||||
isWsConnected: false,
|
||||
addToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearAllToasts: vi.fn(),
|
||||
connectWebSocket: vi.fn(),
|
||||
disconnectWebSocket: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { useCodexLensConfig, useUpdateCodexLensConfig, useNotifications } from '@/hooks';
|
||||
|
||||
const mockConfig: CodexLensConfig = {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
};
|
||||
|
||||
describe('SettingsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when enabled and config loaded', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render current info card', () => {
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
expect(screen.getByText(/Current Index Count/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Current Workers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Current Batch Size/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render configuration form', () => {
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
expect(screen.getByText(/Basic Configuration/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Index Directory/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Max Workers/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Batch Size/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should initialize form with config values', () => {
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
|
||||
const maxWorkersInput = screen.getByLabelText(/Max Workers/i) as HTMLInputElement;
|
||||
const batchSizeInput = screen.getByLabelText(/Batch Size/i) as HTMLInputElement;
|
||||
|
||||
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
|
||||
expect(maxWorkersInput.value).toBe('4');
|
||||
expect(batchSizeInput.value).toBe('8');
|
||||
});
|
||||
|
||||
it('should show save button enabled when changes are made', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
await user.type(indexDirInput, '/new/index/path');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should disable save and reset buttons when no changes', () => {
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
const resetButton = screen.getByText(/Reset/i);
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
expect(resetButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should call updateConfig on save', async () => {
|
||||
const updateConfig = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig,
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const success = vi.fn();
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
success,
|
||||
error: vi.fn(),
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
await user.type(indexDirInput, '/new/index/path');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateConfig).toHaveBeenCalledWith({
|
||||
index_dir: '/new/index/path',
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset form on reset button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
|
||||
await user.clear(indexDirInput);
|
||||
await user.type(indexDirInput, '/new/index/path');
|
||||
|
||||
expect(indexDirInput.value).toBe('/new/index/path');
|
||||
|
||||
const resetButton = screen.getByText(/Reset/i);
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate index dir is required', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate max workers range (1-32)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
|
||||
await user.clear(maxWorkersInput);
|
||||
await user.type(maxWorkersInput, '0');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate max workers upper bound', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const maxWorkersInput = screen.getByLabelText(/Max Workers/i);
|
||||
await user.clear(maxWorkersInput);
|
||||
await user.type(maxWorkersInput, '33');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Workers must be between 1 and 32/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate batch size range (1-64)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
|
||||
await user.clear(batchSizeInput);
|
||||
await user.type(batchSizeInput, '0');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate batch size upper bound', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const batchSizeInput = screen.getByLabelText(/Batch Size/i);
|
||||
await user.clear(batchSizeInput);
|
||||
await user.type(batchSizeInput, '65');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Batch size must be between 1 and 64/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear error when user fixes invalid input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
|
||||
|
||||
await user.type(indexDirInput, '/valid/path');
|
||||
|
||||
expect(screen.queryByText(/Index directory is required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when disabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render when enabled is false', () => {
|
||||
render(<SettingsTab enabled={false} />);
|
||||
|
||||
// When not enabled, the component may render nothing or an empty state
|
||||
// This test documents the expected behavior
|
||||
expect(screen.queryByText(/Basic Configuration/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should disable inputs when loading config', () => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
expect(indexDirInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show saving state when updating', async () => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdating: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
await user.type(indexDirInput, '/new/path');
|
||||
|
||||
const saveButton = screen.getByText(/Saving/i);
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n - Chinese locale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: true }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display translated labels', () => {
|
||||
render(<SettingsTab enabled={true} />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/当前索引数量/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/当前工作线程/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/当前批次大小/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/基本配置/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/索引目录/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/最大工作线程/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/批次大小/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/保存/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/重置/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display translated validation errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />, { locale: 'zh' });
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/索引目录/i);
|
||||
await user.clear(indexDirInput);
|
||||
|
||||
const saveButton = screen.getByText(/保存/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(screen.getByText(/索引目录不能为空/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should show error notification on save failure', async () => {
|
||||
const error = vi.fn();
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
success: vi.fn(),
|
||||
error,
|
||||
toasts: [],
|
||||
wsStatus: 'disconnected' as const,
|
||||
wsLastMessage: null,
|
||||
isWsConnected: false,
|
||||
addToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
connectWebSocket: vi.fn(),
|
||||
disconnectWebSocket: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensConfig).mockReturnValue({
|
||||
config: mockConfig,
|
||||
indexDir: mockConfig.index_dir,
|
||||
indexCount: 100,
|
||||
apiMaxWorkers: 4,
|
||||
apiBatchSize: 8,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useUpdateCodexLensConfig).mockReturnValue({
|
||||
updateConfig: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
|
||||
isUpdating: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsTab enabled={true} />);
|
||||
|
||||
const indexDirInput = screen.getByLabelText(/Index Directory/i);
|
||||
await user.clear(indexDirInput);
|
||||
await user.type(indexDirInput, '/new/path');
|
||||
|
||||
const saveButton = screen.getByText(/Save/i);
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Save failed'),
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
272
ccw/frontend/src/components/codexlens/SettingsTab.tsx
Normal file
272
ccw/frontend/src/components/codexlens/SettingsTab.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// ========================================
|
||||
// CodexLens Settings Tab
|
||||
// ========================================
|
||||
// Configuration form for basic CodexLens settings
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Save, RefreshCw } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useCodexLensConfig, useUpdateCodexLensConfig } from '@/hooks';
|
||||
import { useNotifications } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsTabProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
index_dir?: string;
|
||||
api_max_workers?: string;
|
||||
api_batch_size?: string;
|
||||
}
|
||||
|
||||
export function SettingsTab({ enabled = true }: SettingsTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
|
||||
const {
|
||||
config,
|
||||
indexCount,
|
||||
apiMaxWorkers,
|
||||
apiBatchSize,
|
||||
isLoading: isLoadingConfig,
|
||||
refetch,
|
||||
} = useCodexLensConfig({ enabled });
|
||||
|
||||
const { updateConfig, isUpdating } = useUpdateCodexLensConfig();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
index_dir: '',
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Initialize form from config
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
index_dir: config.index_dir || '',
|
||||
api_max_workers: config.api_max_workers || 4,
|
||||
api_batch_size: config.api_batch_size || 8,
|
||||
});
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleFieldChange = (field: keyof typeof formData, value: string | number) => {
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [field]: value };
|
||||
// Check if there are changes
|
||||
if (config) {
|
||||
const changed =
|
||||
newData.index_dir !== config.index_dir ||
|
||||
newData.api_max_workers !== config.api_max_workers ||
|
||||
newData.api_batch_size !== config.api_batch_size;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
// Clear error for this field
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
// Index dir required
|
||||
if (!formData.index_dir.trim()) {
|
||||
newErrors.index_dir = formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' });
|
||||
}
|
||||
|
||||
// API max workers: 1-32
|
||||
if (formData.api_max_workers < 1 || formData.api_max_workers > 32) {
|
||||
newErrors.api_max_workers = formatMessage({ id: 'codexlens.settings.validation.maxWorkersRange' });
|
||||
}
|
||||
|
||||
// API batch size: 1-64
|
||||
if (formData.api_batch_size < 1 || formData.api_batch_size > 64) {
|
||||
newErrors.api_batch_size = formatMessage({ id: 'codexlens.settings.validation.batchSizeRange' });
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateConfig({
|
||||
index_dir: formData.index_dir,
|
||||
api_max_workers: formData.api_max_workers,
|
||||
api_batch_size: formData.api_batch_size,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
success(
|
||||
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
|
||||
result.message || formatMessage({ id: 'codexlens.settings.configUpdated' })
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.settings.saveFailed' }),
|
||||
result.message || formatMessage({ id: 'codexlens.settings.saveError' })
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(
|
||||
formatMessage({ id: 'codexlens.settings.saveFailed' }),
|
||||
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.settings.unknownError' })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (config) {
|
||||
setFormData({
|
||||
index_dir: config.index_dir || '',
|
||||
api_max_workers: config.api_max_workers || 4,
|
||||
api_batch_size: config.api_batch_size || 8,
|
||||
});
|
||||
setErrors({});
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingConfig;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Info Card */}
|
||||
<Card className="p-4 bg-muted/30">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentCount' })}</span>
|
||||
<p className="text-foreground font-medium">{indexCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentWorkers' })}</span>
|
||||
<p className="text-foreground font-medium">{apiMaxWorkers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.settings.currentBatchSize' })}</span>
|
||||
<p className="text-foreground font-medium">{apiBatchSize}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Configuration Form */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'codexlens.settings.configTitle' })}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Index Directory */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="index_dir">
|
||||
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="index_dir"
|
||||
value={formData.index_dir}
|
||||
onChange={(e) => handleFieldChange('index_dir', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'codexlens.settings.indexDir.placeholder' })}
|
||||
error={!!errors.index_dir}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.index_dir && (
|
||||
<p className="text-sm text-destructive">{errors.index_dir}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Max Workers */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_max_workers">
|
||||
{formatMessage({ id: 'codexlens.settings.maxWorkers.label' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="api_max_workers"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
value={formData.api_max_workers}
|
||||
onChange={(e) => handleFieldChange('api_max_workers', parseInt(e.target.value) || 1)}
|
||||
error={!!errors.api_max_workers}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.api_max_workers && (
|
||||
<p className="text-sm text-destructive">{errors.api_max_workers}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.settings.maxWorkers.hint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Batch Size */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_batch_size">
|
||||
{formatMessage({ id: 'codexlens.settings.batchSize.label' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="api_batch_size"
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={formData.api_batch_size}
|
||||
onChange={(e) => handleFieldChange('api_batch_size', parseInt(e.target.value) || 1)}
|
||||
error={!!errors.api_batch_size}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.api_batch_size && (
|
||||
<p className="text-sm text-destructive">{errors.api_batch_size}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.settings.batchSize.hint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isUpdating || !hasChanges}
|
||||
>
|
||||
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
|
||||
{isUpdating
|
||||
? formatMessage({ id: 'codexlens.settings.saving' })
|
||||
: formatMessage({ id: 'codexlens.settings.save' })
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'codexlens.settings.reset' })}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsTab;
|
||||
@@ -56,6 +56,19 @@ export interface HookQuickTemplatesProps {
|
||||
* Predefined hook templates for quick installation
|
||||
*/
|
||||
export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
{
|
||||
id: 'ccw-status-tracker',
|
||||
name: 'CCW Status Tracker',
|
||||
description: 'Parse CCW status.json and display current/next command',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'Write',
|
||||
command: 'bash',
|
||||
args: [
|
||||
'-c',
|
||||
'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && [[ "$FILE_PATH" == *"status.json" ]] && ccw hook parse-status --path "$FILE_PATH" || true'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ccw-notify',
|
||||
name: 'CCW Dashboard Notify',
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Download, FileText, BarChart3, Info } from 'lucide-react';
|
||||
import { Download, FileText, BarChart3, Info, Upload } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
|
||||
import type { DiscoverySession, Finding } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import type { FindingFilters } from '@/hooks/useIssues';
|
||||
import { FindingList } from './FindingList';
|
||||
|
||||
@@ -22,6 +24,9 @@ interface DiscoveryDetailProps {
|
||||
filters: FindingFilters;
|
||||
onFilterChange: (filters: FindingFilters) => void;
|
||||
onExport: () => void;
|
||||
onExportSelected?: (findingIds: string[]) => Promise<{ success: boolean; message?: string; exported?: number }>;
|
||||
isExporting?: boolean;
|
||||
issues?: Issue[]; // Optional: pass issues to find related ones
|
||||
}
|
||||
|
||||
export function DiscoveryDetail({
|
||||
@@ -31,9 +36,35 @@ export function DiscoveryDetail({
|
||||
filters,
|
||||
onFilterChange,
|
||||
onExport,
|
||||
onExportSelected,
|
||||
isExporting = false,
|
||||
issues = [],
|
||||
}: DiscoveryDetailProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('findings');
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const handleFindingClick = (finding: Finding) => {
|
||||
// If finding has an associated issue_id, find and show that issue
|
||||
if (finding.issue_id) {
|
||||
const relatedIssue = issues.find(i => i.id === finding.issue_id);
|
||||
if (relatedIssue) {
|
||||
setSelectedIssue(relatedIssue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setSelectedIssue(null);
|
||||
};
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
if (onExportSelected && selectedIds.length > 0) {
|
||||
await onExportSelected(selectedIds);
|
||||
setSelectedIds([]); // Clear selection after export
|
||||
}
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -73,10 +104,25 @@ export function DiscoveryDetail({
|
||||
{formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onExport} disabled={findings.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.export' })}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.length > 0 && onExportSelected && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleExportSelected}
|
||||
disabled={isExporting || selectedIds.length === 0}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isExporting
|
||||
? formatMessage({ id: 'issues.discovery.exporting' })
|
||||
: formatMessage({ id: 'issues.discovery.exportSelected' }, { count: selectedIds.length })
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onExport} disabled={findings.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.export' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
@@ -125,7 +171,14 @@ export function DiscoveryDetail({
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="findings" className="mt-4">
|
||||
<FindingList findings={findings} filters={filters} onFilterChange={onFilterChange} />
|
||||
<FindingList
|
||||
findings={findings}
|
||||
filters={filters}
|
||||
onFilterChange={onFilterChange}
|
||||
onFindingClick={handleFindingClick}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={onExportSelected ? setSelectedIds : undefined}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="progress" className="mt-4 space-y-4">
|
||||
@@ -219,6 +272,15 @@ export function DiscoveryDetail({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Issue Detail Drawer */}
|
||||
<IssueDrawer
|
||||
issue={selectedIssue}
|
||||
isOpen={selectedIssue !== null}
|
||||
onClose={handleCloseDrawer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiscoveryDetail;
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
// ========================================
|
||||
// Displays findings with filters and severity badges
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Search, FileCode, AlertTriangle } from 'lucide-react';
|
||||
import { Search, FileCode, AlertTriangle, ExternalLink, Check } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Finding } from '@/lib/api';
|
||||
import type { FindingFilters } from '@/hooks/useIssues';
|
||||
|
||||
@@ -16,17 +18,64 @@ interface FindingListProps {
|
||||
findings: Finding[];
|
||||
filters: FindingFilters;
|
||||
onFilterChange: (filters: FindingFilters) => void;
|
||||
onFindingClick?: (finding: Finding) => void;
|
||||
selectedIds?: string[];
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
critical: { variant: 'destructive' as const, label: 'issues.discovery.severity.critical' },
|
||||
high: { variant: 'destructive' as const, label: 'issues.discovery.severity.high' },
|
||||
medium: { variant: 'warning' as const, label: 'issues.discovery.severity.medium' },
|
||||
low: { variant: 'secondary' as const, label: 'issues.discovery.severity.low' },
|
||||
const severityConfig: Record<string, { variant: 'destructive' | 'warning' | 'secondary' | 'outline' | 'success' | 'info' | 'default'; label: string }> = {
|
||||
critical: { variant: 'destructive', label: 'issues.discovery.severity.critical' },
|
||||
high: { variant: 'destructive', label: 'issues.discovery.severity.high' },
|
||||
medium: { variant: 'warning', label: 'issues.discovery.severity.medium' },
|
||||
low: { variant: 'secondary', label: 'issues.discovery.severity.low' },
|
||||
};
|
||||
|
||||
export function FindingList({ findings, filters, onFilterChange }: FindingListProps) {
|
||||
function getSeverityConfig(severity: string) {
|
||||
return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.severity.unknown' };
|
||||
}
|
||||
|
||||
export function FindingList({
|
||||
findings,
|
||||
filters,
|
||||
onFilterChange,
|
||||
onFindingClick,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
}: FindingListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [internalSelection, setInternalSelection] = useState<Set<string>>(new Set());
|
||||
|
||||
// Use external selection if provided, otherwise use internal state
|
||||
const selectionSet = onSelectionChange
|
||||
? new Set(selectedIds)
|
||||
: internalSelection;
|
||||
|
||||
const handleToggleSelection = (findingId: string) => {
|
||||
const newSet = new Set(selectionSet);
|
||||
if (newSet.has(findingId)) {
|
||||
newSet.delete(findingId);
|
||||
} else {
|
||||
newSet.add(findingId);
|
||||
}
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(Array.from(newSet));
|
||||
} else {
|
||||
setInternalSelection(newSet);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const allSelected = selectionSet.size === findings.length && findings.length > 0;
|
||||
const newSet = allSelected ? new Set<string>() : new Set(findings.map(f => f.id));
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(Array.from(newSet));
|
||||
} else {
|
||||
setInternalSelection(newSet);
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = findings.length > 0 && selectionSet.size === findings.length;
|
||||
const isSomeSelected = selectionSet.size > 0 && selectionSet.size < findings.length;
|
||||
|
||||
// Extract unique types for filter
|
||||
const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort();
|
||||
@@ -36,10 +85,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
|
||||
<Card className="p-8 text-center">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.noFindings' })}
|
||||
{formatMessage({ id: 'issues.discovery.findings.noFindings' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.noFindingsDescription' })}
|
||||
{formatMessage({ id: 'issues.discovery.findings.noFindingsDescription' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
@@ -52,7 +101,7 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'issues.discovery.searchPlaceholder' })}
|
||||
placeholder={formatMessage({ id: 'issues.discovery.findings.searchPlaceholder' })}
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => onFilterChange({ ...filters, search: e.target.value || undefined })}
|
||||
className="pl-9"
|
||||
@@ -63,10 +112,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
|
||||
onValueChange={(v) => onFilterChange({ ...filters, severity: v === 'all' ? undefined : v as Finding['severity'] })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterBySeverity' })} />
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterBySeverity' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allSeverities' })}</SelectItem>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.severity.all' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem>
|
||||
@@ -79,50 +128,155 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
|
||||
onValueChange={(v) => onFilterChange({ ...filters, type: v === 'all' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterByType' })} />
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByType' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allTypes' })}</SelectItem>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.type.all' })}</SelectItem>
|
||||
{uniqueTypes.map(type => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Select
|
||||
value={filters.exported === undefined ? 'all' : filters.exported ? 'exported' : 'notExported'}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'all') {
|
||||
onFilterChange({ ...filters, exported: undefined });
|
||||
} else if (v === 'exported') {
|
||||
onFilterChange({ ...filters, exported: true });
|
||||
} else {
|
||||
onFilterChange({ ...filters, exported: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByExported' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.all' })}</SelectItem>
|
||||
<SelectItem value="exported">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.exported' })}</SelectItem>
|
||||
<SelectItem value="notExported">{formatMessage({ id: 'issues.discovery.findings.exportedStatus.notExported' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.hasIssue === undefined ? 'all' : filters.hasIssue ? 'hasIssue' : 'noIssue'}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'all') {
|
||||
onFilterChange({ ...filters, hasIssue: undefined });
|
||||
} else if (v === 'hasIssue') {
|
||||
onFilterChange({ ...filters, hasIssue: true });
|
||||
} else {
|
||||
onFilterChange({ ...filters, hasIssue: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.findings.filterByIssue' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.issueStatus.all' })}</SelectItem>
|
||||
<SelectItem value="hasIssue">{formatMessage({ id: 'issues.discovery.findings.issueStatus.hasIssue' })}</SelectItem>
|
||||
<SelectItem value="noIssue">{formatMessage({ id: 'issues.discovery.findings.issueStatus.noIssue' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Select All */}
|
||||
{onSelectionChange && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className="w-4 h-4 border rounded flex items-center justify-center">
|
||||
{isAllSelected ? (
|
||||
<Check className="w-3 h-3" />
|
||||
) : isSomeSelected ? (
|
||||
<div className="w-2 h-2 bg-foreground rounded-sm" />
|
||||
) : null}
|
||||
</div>
|
||||
{isAllSelected
|
||||
? formatMessage({ id: 'issues.discovery.findings.deselectAll' })
|
||||
: formatMessage({ id: 'issues.discovery.findings.selectAll' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Findings List */}
|
||||
<div className="space-y-3">
|
||||
{findings.map((finding) => {
|
||||
const config = severityConfig[finding.severity];
|
||||
const config = getSeverityConfig(finding.severity);
|
||||
const isSelected = selectionSet.has(finding.id);
|
||||
return (
|
||||
<Card key={finding.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={config.variant}>
|
||||
{formatMessage({ id: config.label })}
|
||||
</Badge>
|
||||
{finding.type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{finding.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{finding.file && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileCode className="w-3 h-3" />
|
||||
<span>{finding.file}</span>
|
||||
{finding.line && <span>:{finding.line}</span>}
|
||||
<Card
|
||||
key={finding.id}
|
||||
className={cn(
|
||||
"p-4 transition-colors",
|
||||
onFindingClick && "cursor-pointer hover:bg-muted/50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Don't trigger finding click when clicking checkbox
|
||||
if ((e.target as HTMLElement).closest('.selection-checkbox')) return;
|
||||
onFindingClick?.(finding);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Checkbox */}
|
||||
{onSelectionChange && (
|
||||
<div
|
||||
className="selection-checkbox flex-shrink-0 mt-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleSelection(finding.id);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 border rounded flex items-center justify-center cursor-pointer transition-colors",
|
||||
isSelected ? "bg-primary border-primary" : "border-border hover:border-primary"
|
||||
)}>
|
||||
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={config.variant}>
|
||||
{formatMessage({ id: config.label })}
|
||||
</Badge>
|
||||
{finding.type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{finding.type}
|
||||
</Badge>
|
||||
)}
|
||||
{finding.exported && (
|
||||
<Badge variant="success" className="text-xs gap-1">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.exported' })}
|
||||
</Badge>
|
||||
)}
|
||||
{finding.issue_id && (
|
||||
<Badge variant="info" className="text-xs">
|
||||
{formatMessage({ id: 'issues.discovery.findings.hasIssue' })}: {finding.issue_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{finding.file && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileCode className="w-3 h-3" />
|
||||
<span>{finding.file}</span>
|
||||
{finding.line && <span>:{finding.line}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">{finding.title}</h4>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{finding.description}</p>
|
||||
{finding.code_snippet && (
|
||||
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
|
||||
<code>{finding.code_snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">{finding.title}</h4>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{finding.description}</p>
|
||||
{finding.code_snippet && (
|
||||
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
|
||||
<code>{finding.code_snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
@@ -130,8 +284,10 @@ export function FindingList({ findings, filters, onFilterChange }: FindingListPr
|
||||
|
||||
{/* Count */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.showingCount' }, { count: findings.length })}
|
||||
{formatMessage({ id: 'issues.discovery.findings.showingCount' }, { count: findings.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FindingList;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
|
||||
import { Radar, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIssueDiscovery } from '@/hooks/useIssues';
|
||||
import { useIssueDiscovery, useIssues } from '@/hooks/useIssues';
|
||||
import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard';
|
||||
import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
|
||||
|
||||
@@ -27,8 +27,16 @@ export function DiscoveryPanel() {
|
||||
setFilters,
|
||||
selectSession,
|
||||
exportFindings,
|
||||
exportSelectedFindings,
|
||||
isExporting,
|
||||
} = useIssueDiscovery({ refetchInterval: 3000 });
|
||||
|
||||
// Fetch issues to find related ones when clicking findings
|
||||
const { issues } = useIssues({
|
||||
// Don't apply filters to get all issues for matching
|
||||
filter: undefined
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
@@ -144,6 +152,9 @@ export function DiscoveryPanel() {
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
onExport={exportFindings}
|
||||
onExportSelected={exportSelectedFindings}
|
||||
isExporting={isExporting}
|
||||
issues={issues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
238
ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
Normal file
238
ccw/frontend/src/components/issue/hub/IssueDrawer.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
// ========================================
|
||||
// IssueDrawer Component
|
||||
// ========================================
|
||||
// Right-side issue detail drawer with Overview/Solutions/History tabs
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Issue } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface IssueDrawerProps {
|
||||
issue: Issue | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabValue = 'overview' | 'solutions' | 'history' | 'json';
|
||||
|
||||
// ========== Status Configuration ==========
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
open: { label: 'issues.status.open', variant: 'info', icon: Circle },
|
||||
in_progress: { label: 'issues.status.inProgress', variant: 'warning', icon: Loader2 },
|
||||
resolved: { label: 'issues.status.resolved', variant: 'success', icon: CheckCircle },
|
||||
closed: { label: 'issues.status.closed', variant: 'secondary', icon: Circle },
|
||||
completed: { label: 'issues.status.completed', variant: 'success', icon: CheckCircle },
|
||||
};
|
||||
|
||||
const priorityConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' }> = {
|
||||
low: { label: 'issues.priority.low', variant: 'secondary' },
|
||||
medium: { label: 'issues.priority.medium', variant: 'default' },
|
||||
high: { label: 'issues.priority.high', variant: 'warning' },
|
||||
critical: { label: 'issues.priority.critical', variant: 'destructive' },
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function IssueDrawer({ issue, isOpen, onClose }: IssueDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('overview');
|
||||
|
||||
// Reset to overview when issue changes
|
||||
useState(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
|
||||
if (!issue || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = statusConfig[issue.status] || statusConfig.open;
|
||||
const priority = priorityConfig[issue.priority] || priorityConfig.medium;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ minWidth: '400px', maxWidth: '800px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">{issue.id}</span>
|
||||
<Badge variant={status.variant} className="gap-1">
|
||||
<status.icon className="h-3 w-3" />
|
||||
{formatMessage({ id: status.label })}
|
||||
</Badge>
|
||||
<Badge variant={priority.variant}>
|
||||
{formatMessage({ id: priority.label })}
|
||||
</Badge>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{issue.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-6 pt-4 bg-card">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.detail.tabs.overview' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="solutions" className="flex-1">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.detail.tabs.solutions' })}
|
||||
{issue.solutions && issue.solutions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{issue.solutions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="flex-1">
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.detail.tabs.history' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
<Hash className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="space-y-6">
|
||||
{/* Context */}
|
||||
{issue.context && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'issues.detail.overview.context' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{issue.context}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'issues.detail.overview.labels' })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{issue.labels.map((label, index) => (
|
||||
<Badge key={index} variant="outline" className="gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.detail.overview.createdAt' })}</p>
|
||||
<p className="text-sm">{new Date(issue.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{issue.updatedAt && (
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.detail.overview.updatedAt' })}</p>
|
||||
<p className="text-sm">{new Date(issue.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Solutions Tab */}
|
||||
<TabsContent value="solutions" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
{!issue.solutions || issue.solutions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CheckCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'issues.detail.solutions.empty' })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{issue.solutions.map((solution, index) => (
|
||||
<div key={solution.id || index} className="p-4 bg-muted/50 rounded-md border border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge variant={solution.status === 'completed' ? 'success' : 'secondary'}>
|
||||
{solution.status}
|
||||
</Badge>
|
||||
{solution.estimatedEffort && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{solution.estimatedEffort}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">{solution.description}</p>
|
||||
{solution.approach && (
|
||||
<p className="text-xs text-muted-foreground">{solution.approach}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'issues.detail.history.empty' })}</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Tab */}
|
||||
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">
|
||||
{JSON.stringify(issue, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueDrawer;
|
||||
@@ -6,115 +6,27 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Github,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
} 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 { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { IssueCard } from '@/components/shared/IssueCard';
|
||||
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
|
||||
import { useIssues, useIssueMutations } from '@/hooks';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StatusFilter = 'all' | Issue['status'];
|
||||
type PriorityFilter = 'all' | Issue['priority'];
|
||||
|
||||
interface NewIssueDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
interface IssuesPanelProps {
|
||||
onCreateIssue?: () => void;
|
||||
}
|
||||
|
||||
interface IssueListProps {
|
||||
@@ -165,14 +77,14 @@ function IssueList({ issues, isLoading, onIssueClick, onIssueEdit, onIssueDelete
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuesPanel() {
|
||||
export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
|
||||
const { issues, issuesByStatus, openCount, criticalCount, isLoading, isFetching, refetch } = useIssues({
|
||||
const { issues, issuesByStatus, openCount, criticalCount, isLoading } = useIssues({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? [statusFilter] : undefined,
|
||||
@@ -180,7 +92,7 @@ export function IssuesPanel() {
|
||||
},
|
||||
});
|
||||
|
||||
const { createIssue, updateIssue, deleteIssue, isCreating } = useIssueMutations();
|
||||
const { updateIssue, deleteIssue } = useIssueMutations();
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
all: issues.length,
|
||||
@@ -191,11 +103,6 @@ export function IssuesPanel() {
|
||||
completed: issuesByStatus.completed?.length || 0,
|
||||
}), [issues, issuesByStatus]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
};
|
||||
|
||||
const handleEditIssue = (_issue: Issue) => {};
|
||||
|
||||
const handleDeleteIssue = async (issue: Issue) => {
|
||||
@@ -208,23 +115,16 @@ export function IssuesPanel() {
|
||||
await updateIssue(issue.id, { status });
|
||||
};
|
||||
|
||||
const handleIssueClick = (issue: Issue) => {
|
||||
setSelectedIssue(issue);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setSelectedIssue(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.github' })}
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.create' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -312,9 +212,21 @@ export function IssuesPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<IssueList issues={issues} isLoading={isLoading} onIssueClick={() => {}} onIssueEdit={handleEditIssue} onIssueDelete={handleDeleteIssue} onStatusChange={handleStatusChange} />
|
||||
<IssueList
|
||||
issues={issues}
|
||||
isLoading={isLoading}
|
||||
onIssueClick={handleIssueClick}
|
||||
onIssueEdit={handleEditIssue}
|
||||
onIssueDelete={handleDeleteIssue}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||
{/* Issue Detail Drawer */}
|
||||
<IssueDrawer
|
||||
issue={selectedIssue}
|
||||
isOpen={selectedIssue !== null}
|
||||
onClose={handleCloseDrawer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// ========================================
|
||||
// Content panel for Queue tab in IssueHub
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
GitMerge,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { QueueCard } from '@/components/issue/queue/QueueCard';
|
||||
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
|
||||
import { useIssueQueue, useQueueMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Loading Skeleton ==========
|
||||
|
||||
@@ -70,17 +70,20 @@ function QueueEmptyState() {
|
||||
|
||||
export function QueuePanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
|
||||
|
||||
const { data: queueData, isLoading, isFetching, refetch, error } = useIssueQueue();
|
||||
const { data: queueData, isLoading, error } = useIssueQueue();
|
||||
const {
|
||||
activateQueue,
|
||||
deactivateQueue,
|
||||
deleteQueue,
|
||||
mergeQueues,
|
||||
splitQueue,
|
||||
isActivating,
|
||||
isDeactivating,
|
||||
isDeleting,
|
||||
isMerging,
|
||||
isSplitting,
|
||||
} = useQueueMutations();
|
||||
|
||||
// Get queue data with proper type
|
||||
@@ -123,6 +126,22 @@ export function QueuePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplit = async (sourceQueueId: string, itemIds: string[]) => {
|
||||
try {
|
||||
await splitQueue(sourceQueueId, itemIds);
|
||||
} catch (err) {
|
||||
console.error('Failed to split queue:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: QueueItem) => {
|
||||
setSelectedItem(item);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <QueuePanelSkeleton />;
|
||||
}
|
||||
@@ -150,18 +169,6 @@ export function QueuePanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
@@ -229,13 +236,23 @@ export function QueuePanel() {
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onMerge={handleMerge}
|
||||
onSplit={handleSplit}
|
||||
onItemClick={handleItemClick}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
isSplitting={isSplitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Solution Detail Drawer */}
|
||||
<SolutionDrawer
|
||||
item={selectedItem}
|
||||
isOpen={selectedItem !== null}
|
||||
onClose={handleCloseDrawer}
|
||||
/>
|
||||
|
||||
{/* Status Footer */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -9,20 +9,22 @@ import { ChevronDown, ChevronRight, GitMerge, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ExecutionGroupProps {
|
||||
group: string;
|
||||
items: string[];
|
||||
items: QueueItem[];
|
||||
type?: 'parallel' | 'sequential';
|
||||
onItemClick?: (item: QueueItem) => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionGroupProps) {
|
||||
export function ExecutionGroup({ group, items, type = 'sequential', onItemClick }: ExecutionGroupProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isParallel = type === 'parallel';
|
||||
|
||||
return (
|
||||
@@ -56,7 +58,7 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||
{formatMessage({ id: 'issues.queue.itemCount' }, { count: items.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -67,22 +69,38 @@ export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionG
|
||||
"space-y-1 mt-2",
|
||||
isParallel ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "space-y-1"
|
||||
)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm",
|
||||
"hover:bg-muted transition-colors"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs w-6">
|
||||
{isParallel ? '' : `${index + 1}.`}
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{item}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
// Parse item_id to extract type and ID
|
||||
const [itemType, ...idParts] = item.item_id.split('-');
|
||||
const displayId = idParts.join('-');
|
||||
const typeLabel = itemType === 'issue' ? formatMessage({ id: 'issues.solution.shortIssue' })
|
||||
: itemType === 'solution' ? formatMessage({ id: 'issues.solution.shortSolution' })
|
||||
: itemType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.item_id}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm",
|
||||
"hover:bg-muted transition-colors cursor-pointer"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs w-6">
|
||||
{isParallel ? '' : `${index + 1}.`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{displayId}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{formatMessage({ id: `issues.queue.status.${item.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
// ========================================
|
||||
// QueueActions Component
|
||||
// ========================================
|
||||
// Queue operations menu component with delete confirmation and merge dialog
|
||||
// Queue operations with direct action buttons (no dropdown menu)
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Play, Pause, Trash2, Merge, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Play, Pause, Trash2, Merge, GitBranch, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -32,7 +25,9 @@ import {
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { IssueQueue } from '@/lib/api';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueQueue, QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -43,10 +38,12 @@ export interface QueueActionsProps {
|
||||
onDeactivate?: () => void;
|
||||
onDelete?: (queueId: string) => void;
|
||||
onMerge?: (sourceId: string, targetId: string) => void;
|
||||
onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
|
||||
isActivating?: boolean;
|
||||
isDeactivating?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isMerging?: boolean;
|
||||
isSplitting?: boolean;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -58,18 +55,25 @@ export function QueueActions({
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onMerge,
|
||||
onSplit,
|
||||
isActivating = false,
|
||||
isDeactivating = false,
|
||||
isDeleting = false,
|
||||
isMerging = false,
|
||||
isSplitting = false,
|
||||
}: QueueActionsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isMergeOpen, setIsMergeOpen] = useState(false);
|
||||
const [isSplitOpen, setIsSplitOpen] = useState(false);
|
||||
const [mergeTargetId, setMergeTargetId] = useState('');
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
|
||||
|
||||
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
|
||||
const queueId = queue.tasks.join(',') || queue.solutions.join(',');
|
||||
const queueId = (queue.tasks?.join(',') || queue.solutions?.join(',') || 'default');
|
||||
|
||||
// Get all items from grouped_items for split dialog
|
||||
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete?.(queueId);
|
||||
@@ -84,68 +88,122 @@ export function QueueActions({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplit = () => {
|
||||
if (selectedItemIds.length > 0 && selectedItemIds.length < allItems.length) {
|
||||
onSplit?.(queueId, selectedItemIds);
|
||||
setIsSplitOpen(false);
|
||||
setSelectedItemIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
setSelectedItemIds(prev =>
|
||||
prev.includes(itemId)
|
||||
? prev.filter(id => id !== itemId)
|
||||
: [...prev, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedItemIds(allItems.map(item => item.item_id));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setSelectedItemIds([]);
|
||||
};
|
||||
|
||||
// Calculate item count
|
||||
const totalItems = (queue.tasks?.length || 0) + (queue.solutions?.length || 0);
|
||||
const canSplit = totalItems > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{formatMessage({ id: 'common.actions.openMenu' })}</span>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && onActivate && (
|
||||
<DropdownMenuItem onClick={() => onActivate(queueId)} disabled={isActivating}>
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.activate' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isActive && onDeactivate && (
|
||||
<DropdownMenuItem onClick={() => onDeactivate()} disabled={isDeactivating}>
|
||||
{isDeactivating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.deactivate' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setIsMergeOpen(true)} disabled={isMerging}>
|
||||
{isMerging ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Merge className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.merge' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
className="text-destructive"
|
||||
{/* Direct action buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Activate/Deactivate button */}
|
||||
{!isActive && onActivate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onActivate(queueId)}
|
||||
disabled={isActivating}
|
||||
title={formatMessage({ id: 'issues.queue.actions.activate' })}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Play className="w-4 h-4 text-success" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.delete' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Button>
|
||||
)}
|
||||
{isActive && onDeactivate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onDeactivate()}
|
||||
disabled={isDeactivating}
|
||||
title={formatMessage({ id: 'issues.queue.actions.deactivate' })}
|
||||
>
|
||||
{isDeactivating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-warning" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Merge button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsMergeOpen(true)}
|
||||
disabled={isMerging}
|
||||
title={formatMessage({ id: 'issues.queue.actions.merge' })}
|
||||
>
|
||||
{isMerging ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Merge className="w-4 h-4 text-info" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Split button - only show if more than 1 item */}
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsSplitOpen(true)}
|
||||
disabled={isSplitting}
|
||||
title={formatMessage({ id: 'issues.queue.actions.split' })}
|
||||
>
|
||||
{isSplitting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
title={formatMessage({ id: 'issues.queue.actions.delete' })}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-destructive" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
@@ -227,6 +285,100 @@ export function QueueActions({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Split Dialog */}
|
||||
<Dialog open={isSplitOpen} onOpenChange={setIsSplitOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden flex flex-col py-4">
|
||||
{/* Selection info */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.selected' }, { count: selectedItemIds.length, total: allItems.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.selectAll' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearAll}>
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.clearAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items list with checkboxes */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{allItems.map((item) => {
|
||||
const isSelected = selectedItemIds.includes(item.item_id);
|
||||
return (
|
||||
<div
|
||||
key={item.item_id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-md border transition-colors cursor-pointer",
|
||||
isSelected ? "bg-primary/10 border-primary" : "bg-card hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => toggleItemSelection(item.item_id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleItemSelection(item.item_id)}
|
||||
/>
|
||||
<span className="font-mono text-xs flex-1 truncate">
|
||||
{item.item_id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: `issues.queue.status.${item.status}` })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Validation message */}
|
||||
{selectedItemIds.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.noSelection' })}
|
||||
</p>
|
||||
)}
|
||||
{selectedItemIds.length >= allItems.length && (
|
||||
<p className="text-sm text-destructive text-center py-2">
|
||||
{formatMessage({ id: 'issues.queue.splitDialog.cannotSplitAll' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsSplitOpen(false);
|
||||
setSelectedItemIds([]);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSplit}
|
||||
disabled={selectedItemIds.length === 0 || selectedItemIds.length >= allItems.length || isSplitting}
|
||||
>
|
||||
{isSplitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'common.actions.splitting' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitBranch className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.queue.actions.split' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ export interface QueueCardProps {
|
||||
onDeactivate?: () => void;
|
||||
onDelete?: (queueId: string) => void;
|
||||
onMerge?: (sourceId: string, targetId: string) => void;
|
||||
onSplit?: (sourceQueueId: string, itemIds: string[]) => void;
|
||||
onItemClick?: (item: import('@/lib/api').QueueItem) => void;
|
||||
isActivating?: boolean;
|
||||
isDeactivating?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isMerging?: boolean;
|
||||
isSplitting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -37,10 +40,13 @@ export function QueueCard({
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onMerge,
|
||||
onSplit,
|
||||
onItemClick,
|
||||
isActivating = false,
|
||||
isDeactivating = false,
|
||||
isDeleting = false,
|
||||
isMerging = false,
|
||||
isSplitting = false,
|
||||
className,
|
||||
}: QueueCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -101,10 +107,12 @@ export function QueueCard({
|
||||
onDeactivate={onDeactivate}
|
||||
onDelete={onDelete}
|
||||
onMerge={onMerge}
|
||||
onSplit={onSplit}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
isSplitting={isSplitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +151,7 @@ export function QueueCard({
|
||||
group={group.id}
|
||||
items={group.items}
|
||||
type={group.type}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
212
ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
Normal file
212
ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
// ========================================
|
||||
// SolutionDrawer Component
|
||||
// ========================================
|
||||
// Right-side solution detail drawer
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface SolutionDrawerProps {
|
||||
item: QueueItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabValue = 'overview' | 'tasks' | 'json';
|
||||
|
||||
// ========== Status Configuration ==========
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info'; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
pending: { label: 'issues.queue.status.pending', variant: 'secondary', icon: Circle },
|
||||
ready: { label: 'issues.queue.status.ready', variant: 'info', icon: Clock },
|
||||
executing: { label: 'issues.queue.status.executing', variant: 'warning', icon: Loader2 },
|
||||
completed: { label: 'issues.queue.status.completed', variant: 'success', icon: CheckCircle },
|
||||
failed: { label: 'issues.queue.status.failed', variant: 'destructive', icon: XCircle },
|
||||
blocked: { label: 'issues.queue.status.blocked', variant: 'destructive', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('overview');
|
||||
|
||||
// ESC key to close
|
||||
useState(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
|
||||
if (!item || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = statusConfig[item.status] || statusConfig.pending;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
// Get solution details (would need to fetch full solution data)
|
||||
const solutionId = item.solution_id;
|
||||
const issueId = item.issue_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ minWidth: '400px', maxWidth: '800px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-mono text-muted-foreground">{item.item_id}</span>
|
||||
<Badge variant={status.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: status.label })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'solution.issue' })}: <span className="font-mono">{issueId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'solution.solution' })}: <span className="font-mono">{solutionId}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-6 pt-4 bg-card">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.overview' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="flex-1">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.tasks' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.json' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="space-y-6">
|
||||
{/* Execution Info */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
{formatMessage({ id: 'solution.overview.executionInfo' })}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.executionOrder' })}</p>
|
||||
<p className="text-lg font-semibold">{item.execution_order}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.semanticPriority' })}</p>
|
||||
<p className="text-lg font-semibold">{item.semantic_priority}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.group' })}</p>
|
||||
<p className="text-sm font-mono truncate">{item.execution_group}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.taskCount' })}</p>
|
||||
<p className="text-lg font-semibold">{item.task_count || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{item.depends_on && item.depends_on.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'solution.overview.dependencies' })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.depends_on.map((dep, index) => (
|
||||
<Badge key={index} variant="outline" className="font-mono text-xs">
|
||||
{dep}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Touched */}
|
||||
{item.files_touched && item.files_touched.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'solution.overview.filesTouched' })}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{item.files_touched.map((file, index) => (
|
||||
<div key={index} className="p-2 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-mono">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tasks Tab */}
|
||||
<TabsContent value="tasks" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'solution.tasks.comingSoon' })}</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Tab */}
|
||||
<TabsContent value="json" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<pre className="p-4 bg-muted rounded-md overflow-x-auto text-xs">
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SolutionDrawer;
|
||||
@@ -60,8 +60,6 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/orchestrator', icon: Workflow },
|
||||
{ path: '/loops', icon: RefreshCw },
|
||||
{ path: '/issues', icon: AlertCircle },
|
||||
{ path: '/issues?tab=queue', icon: ListTodo },
|
||||
{ path: '/issues?tab=discovery', icon: Search },
|
||||
{ path: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
@@ -69,6 +67,7 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/hooks', icon: GitFork },
|
||||
{ path: '/settings', icon: Settings },
|
||||
{ path: '/settings/rules', icon: Shield },
|
||||
{ path: '/settings/codexlens', icon: Sparkles },
|
||||
{ path: '/help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
@@ -110,8 +109,6 @@ export function Sidebar({
|
||||
'/orchestrator': 'main.orchestrator',
|
||||
'/loops': 'main.loops',
|
||||
'/issues': 'main.issues',
|
||||
'/issues?tab=queue': 'main.issueQueue',
|
||||
'/issues?tab=discovery': 'main.issueDiscovery',
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
@@ -119,6 +116,7 @@ export function Sidebar({
|
||||
'/hooks': 'main.hooks',
|
||||
'/settings': 'main.settings',
|
||||
'/settings/rules': 'main.rules',
|
||||
'/settings/codexlens': 'main.codexlens',
|
||||
'/help': 'main.help',
|
||||
};
|
||||
return navItemDefinitions.map((item) => ({
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// ========================================
|
||||
// AssetsCard Component
|
||||
// ========================================
|
||||
// Displays assets with category tabs and card grid
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText, Code, TestTube } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
|
||||
export interface AssetItem {
|
||||
path: string;
|
||||
relevance_score?: number;
|
||||
scope?: string;
|
||||
contains?: string[];
|
||||
}
|
||||
|
||||
export interface AssetsData {
|
||||
documentation?: AssetItem[];
|
||||
source_code?: AssetItem[];
|
||||
tests?: AssetItem[];
|
||||
}
|
||||
|
||||
export interface AssetsCardProps {
|
||||
data?: AssetsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* AssetsCard component - Displays project assets with categorization
|
||||
*/
|
||||
export function AssetsCard({ data }: AssetsCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!data || (!data.documentation?.length && !data.source_code?.length && !data.tests?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docCount = data.documentation?.length || 0;
|
||||
const sourceCount = data.source_code?.length || 0;
|
||||
const testCount = data.tests?.length || 0;
|
||||
const totalAssets = docCount + sourceCount + testCount;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.context.assets.title' })}
|
||||
<Badge variant="secondary">{totalAssets}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue={docCount > 0 ? 'documentation' : sourceCount > 0 ? 'source_code' : 'tests'}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="documentation" disabled={!docCount}>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'sessionDetail.context.categories.documentation' })}
|
||||
{docCount > 0 && <span className="ml-1 text-xs">({docCount})</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="source_code" disabled={!sourceCount}>
|
||||
<Code className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'sessionDetail.context.categories.sourceCode' })}
|
||||
{sourceCount > 0 && <span className="ml-1 text-xs">({sourceCount})</span>}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tests" disabled={!testCount}>
|
||||
<TestTube className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'sessionDetail.context.categories.tests' })}
|
||||
{testCount > 0 && <span className="ml-1 text-xs">({testCount})</span>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="documentation" className="mt-4">
|
||||
<AssetGrid items={data.documentation || []} type="documentation" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="source_code" className="mt-4">
|
||||
<AssetGrid items={data.source_code || []} type="source_code" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tests" className="mt-4">
|
||||
<AssetGrid items={data.tests || []} type="tests" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssetGridProps {
|
||||
items: AssetItem[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
function AssetGrid({ items }: AssetGridProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.assets.noData' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-mono text-foreground truncate">{item.path}</p>
|
||||
</div>
|
||||
{item.relevance_score !== undefined && (
|
||||
<Badge
|
||||
variant={item.relevance_score > 0.7 ? 'success' : item.relevance_score > 0.4 ? 'default' : 'secondary'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{Math.round(item.relevance_score * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{item.scope && (
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.assets.scope' })}: {item.scope}
|
||||
</span>
|
||||
)}
|
||||
{item.contains && item.contains.length > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.assets.contains' })}: {item.contains.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// ========================================
|
||||
// ConflictDetectionCard Component
|
||||
// ========================================
|
||||
// Displays conflict detection results with risk levels
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { AlertTriangle, Shield, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
|
||||
export interface RiskFactors {
|
||||
test_gaps?: string[];
|
||||
existing_implementations?: string[];
|
||||
}
|
||||
|
||||
export interface ConflictDetectionData {
|
||||
risk_level?: 'low' | 'medium' | 'high' | 'critical';
|
||||
mitigation_strategy?: string;
|
||||
risk_factors?: RiskFactors;
|
||||
affected_modules?: string[];
|
||||
}
|
||||
|
||||
export interface ConflictDetectionCardProps {
|
||||
data?: ConflictDetectionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConflictDetectionCard component - Displays conflict detection results
|
||||
*/
|
||||
export function ConflictDetectionCard({ data }: ConflictDetectionCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!data || !data.risk_level) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const riskConfig = getRiskConfig(data.risk_level);
|
||||
|
||||
return (
|
||||
<Card className={riskConfig.borderClass}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className={`w-5 h-5 ${riskConfig.iconColor}`} />
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.title' })}
|
||||
<Badge variant={riskConfig.badgeVariant} className={riskConfig.badgeClass}>
|
||||
{formatMessage({ id: `sessionDetail.context.conflictDetection.riskLevel.${data.risk_level}` })}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Mitigation Strategy */}
|
||||
{data.mitigation_strategy && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.mitigation' })}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">{data.mitigation_strategy}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Factors */}
|
||||
{data.risk_factors && (
|
||||
<RiskFactorsSection factors={data.risk_factors} />
|
||||
)}
|
||||
|
||||
{/* Affected Modules */}
|
||||
{data.affected_modules && data.affected_modules.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.affectedModules' })}
|
||||
</h4>
|
||||
<FieldRenderer value={data.affected_modules} type="tags" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface RiskFactorsSectionProps {
|
||||
factors: RiskFactors;
|
||||
}
|
||||
|
||||
function RiskFactorsSection({ factors }: RiskFactorsSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const hasTestGaps = factors.test_gaps && factors.test_gaps.length > 0;
|
||||
const hasExistingImpl = factors.existing_implementations && factors.existing_implementations.length > 0;
|
||||
|
||||
if (!hasTestGaps && !hasExistingImpl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.riskFactors' })}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{hasTestGaps && (
|
||||
<div className="p-2 border-l-2 border-warning bg-warning/5 rounded-r">
|
||||
<p className="text-xs font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.testGaps' })}
|
||||
</p>
|
||||
<FieldRenderer value={factors.test_gaps!} type="array" />
|
||||
</div>
|
||||
)}
|
||||
{hasExistingImpl && (
|
||||
<div className="p-2 border-l-2 border-info bg-info/5 rounded-r">
|
||||
<p className="text-xs font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'sessionDetail.context.conflictDetection.existingImplementations' })}
|
||||
</p>
|
||||
<FieldRenderer value={factors.existing_implementations!} type="array" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRiskConfig(level: string) {
|
||||
switch (level) {
|
||||
case 'critical':
|
||||
return {
|
||||
borderClass: 'border-destructive',
|
||||
iconColor: 'text-destructive',
|
||||
badgeVariant: 'destructive' as const,
|
||||
badgeClass: 'bg-destructive text-destructive-foreground',
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
borderClass: 'border-warning',
|
||||
iconColor: 'text-warning',
|
||||
badgeVariant: 'warning' as const,
|
||||
badgeClass: '',
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
borderClass: 'border-info',
|
||||
iconColor: 'text-info',
|
||||
badgeVariant: 'info' as const,
|
||||
badgeClass: '',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
borderClass: '',
|
||||
iconColor: 'text-success',
|
||||
badgeVariant: 'success' as const,
|
||||
badgeClass: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// ========================================
|
||||
// DependenciesCard Component
|
||||
// ========================================
|
||||
// Displays internal and external dependencies
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { GitBranch, Package } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export interface InternalDependency {
|
||||
from: string;
|
||||
type: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ExternalDependency {
|
||||
package: string;
|
||||
version?: string;
|
||||
usage?: string;
|
||||
}
|
||||
|
||||
export interface DependenciesData {
|
||||
internal?: InternalDependency[];
|
||||
external?: ExternalDependency[];
|
||||
}
|
||||
|
||||
export interface DependenciesCardProps {
|
||||
data?: DependenciesData;
|
||||
}
|
||||
|
||||
/**
|
||||
* DependenciesCard component - Displays project dependencies
|
||||
*/
|
||||
export function DependenciesCard({ data }: DependenciesCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!data || (!data.internal?.length && !data.external?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const internalCount = data.internal?.length || 0;
|
||||
const externalCount = data.external?.length || 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.title' })}
|
||||
<Badge variant="secondary">{internalCount + externalCount}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{data.internal && data.internal.length > 0 && (
|
||||
<InternalDependenciesSection dependencies={data.internal} />
|
||||
)}
|
||||
|
||||
{data.external && data.external.length > 0 && (
|
||||
<ExternalDependenciesSection dependencies={data.external} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface InternalDependenciesSectionProps {
|
||||
dependencies: InternalDependency[];
|
||||
}
|
||||
|
||||
function InternalDependenciesSection({ dependencies }: InternalDependenciesSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4" />
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.internal' })} ({dependencies.length})
|
||||
</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.from' })}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.type' })}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.to' })}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{dependencies.map((dep, index) => (
|
||||
<tr key={index} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-2 font-mono text-foreground">{dep.from}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="outline">{dep.type}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-foreground">{dep.to}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExternalDependenciesSectionProps {
|
||||
dependencies: ExternalDependency[];
|
||||
}
|
||||
|
||||
function ExternalDependenciesSection({ dependencies }: ExternalDependenciesSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
{formatMessage({ id: 'sessionDetail.context.dependencies.external' })} ({dependencies.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dependencies.map((dep, index) => (
|
||||
<Badge key={index} variant="secondary" className="px-3 py-1.5">
|
||||
{dep.package}
|
||||
{dep.version && <span className="ml-1 text-muted-foreground">@{dep.version}</span>}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{dependencies.some(d => d.usage) && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{dependencies
|
||||
.filter(d => d.usage)
|
||||
.map((dep, index) => (
|
||||
<div key={index} className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">{dep.package}:</span> {dep.usage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ========================================
|
||||
// ExplorationCollapsible Component
|
||||
// ========================================
|
||||
// Collapsible section for exploration angles
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/Collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ExplorationCollapsibleProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExplorationCollapsible component - Collapsible section for exploration data
|
||||
*/
|
||||
export function ExplorationCollapsible({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
className,
|
||||
}: ExplorationCollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={cn('border rounded-lg', className)}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="p-3 pt-0">
|
||||
<div className="mt-2 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// ========================================
|
||||
// ExplorationsSection Component
|
||||
// ========================================
|
||||
// Displays exploration data with collapsible sections
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
GitBranch,
|
||||
Search,
|
||||
Link,
|
||||
TestTube,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Layers
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { ExplorationCollapsible } from './ExplorationCollapsible';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
|
||||
export interface ExplorationsData {
|
||||
manifest: {
|
||||
task_description: string;
|
||||
complexity?: string;
|
||||
exploration_count: number;
|
||||
};
|
||||
data: Record<string, {
|
||||
project_structure?: string[];
|
||||
relevant_files?: string[];
|
||||
patterns?: string[];
|
||||
dependencies?: string[];
|
||||
integration_points?: string[];
|
||||
testing?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ExplorationsSectionProps {
|
||||
data?: ExplorationsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExplorationsSection component - Displays all exploration angles
|
||||
*/
|
||||
export function ExplorationsSection({ data }: ExplorationsSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!data || !data.data || Object.keys(data.data).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explorationEntries = Object.entries(data.data);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.context.explorations.title' })}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({data.manifest.exploration_count} {formatMessage({ id: 'sessionDetail.context.explorations.angles' })})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{explorationEntries.map(([angle, angleData]) => (
|
||||
<ExplorationCollapsible
|
||||
key={angle}
|
||||
title={formatAngleTitle(angle)}
|
||||
icon={<Search className="w-4 h-4 text-muted-foreground" />}
|
||||
>
|
||||
<AngleContent data={angleData} />
|
||||
</ExplorationCollapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface AngleContentProps {
|
||||
data: {
|
||||
project_structure?: string[];
|
||||
relevant_files?: string[];
|
||||
patterns?: string[];
|
||||
dependencies?: string[];
|
||||
integration_points?: string[];
|
||||
testing?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function AngleContent({ data }: AngleContentProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const sections: Array<{
|
||||
key: string;
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
data: unknown;
|
||||
}> = [];
|
||||
|
||||
if (data.project_structure && data.project_structure.length > 0) {
|
||||
sections.push({
|
||||
key: 'project_structure',
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.projectStructure' }),
|
||||
data: data.project_structure,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.relevant_files && data.relevant_files.length > 0) {
|
||||
sections.push({
|
||||
key: 'relevant_files',
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.relevantFiles' }),
|
||||
data: data.relevant_files,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.patterns && data.patterns.length > 0) {
|
||||
sections.push({
|
||||
key: 'patterns',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.patterns' }),
|
||||
data: data.patterns,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.dependencies && data.dependencies.length > 0) {
|
||||
sections.push({
|
||||
key: 'dependencies',
|
||||
icon: <GitBranch className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.dependencies' }),
|
||||
data: data.dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.integration_points && data.integration_points.length > 0) {
|
||||
sections.push({
|
||||
key: 'integration_points',
|
||||
icon: <Link className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.integrationPoints' }),
|
||||
data: data.integration_points,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.testing && data.testing.length > 0) {
|
||||
sections.push({
|
||||
key: 'testing',
|
||||
icon: <TestTube className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.testing' }),
|
||||
data: data.testing,
|
||||
});
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground italic">No data available</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5">{section.icon}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
{section.label}
|
||||
</p>
|
||||
<FieldRenderer value={section.data} type="array" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAngleTitle(angle: string): string {
|
||||
return angle
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// ========================================
|
||||
// FieldRenderer Component
|
||||
// ========================================
|
||||
// Renders various data types for context display
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
value: unknown;
|
||||
type?: 'string' | 'array' | 'object' | 'files' | 'tags' | 'auto';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FieldRenderer component - Automatically renders different data types
|
||||
*/
|
||||
export function FieldRenderer({ value, type = 'auto', className }: FieldRendererProps) {
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-muted-foreground italic">-</span>;
|
||||
}
|
||||
|
||||
const detectedType = type === 'auto' ? detectType(value) : type;
|
||||
|
||||
switch (detectedType) {
|
||||
case 'array':
|
||||
return <ArrayRenderer value={value as unknown[]} className={className} />;
|
||||
case 'object':
|
||||
return <ObjectRenderer value={value as Record<string, unknown>} className={className} />;
|
||||
case 'files':
|
||||
return <FilesRenderer value={value as Array<{ path: string }>} className={className} />;
|
||||
case 'tags':
|
||||
return <TagsRenderer value={value as string[]} className={className} />;
|
||||
default:
|
||||
return <StringRenderer value={String(value)} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
function detectType(value: unknown): 'string' | 'array' | 'object' | 'files' | 'tags' {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null && 'path' in value[0]) {
|
||||
return 'files';
|
||||
}
|
||||
if (value.length > 0 && typeof value[0] === 'string') {
|
||||
return 'tags';
|
||||
}
|
||||
return 'array';
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return 'object';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function StringRenderer({ value, className }: { value: string; className?: string }) {
|
||||
return <span className={cn('text-foreground', className)}>{value}</span>;
|
||||
}
|
||||
|
||||
function ArrayRenderer({ value, className }: { value: unknown[]; className?: string }) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-muted-foreground italic">Empty</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cn('space-y-1', className)}>
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="text-sm text-foreground flex items-start gap-2">
|
||||
<span className="text-muted-foreground">{index + 1}.</span>
|
||||
<span className="flex-1">{String(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectRenderer({ value, className }: { value: Record<string, unknown>; className?: string }) {
|
||||
const entries = Object.entries(value).filter(([_, v]) => v !== null && v !== undefined);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-muted-foreground italic">Empty</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} className="flex items-start gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground min-w-[100px] capitalize">
|
||||
{formatLabel(key)}:
|
||||
</span>
|
||||
<span className="text-sm text-foreground flex-1">
|
||||
{typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesRenderer({ value, className }: { value: Array<{ path: string }>; className?: string }) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-muted-foreground italic">No files</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
{value.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 bg-muted rounded text-sm font-mono text-foreground"
|
||||
>
|
||||
<FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="truncate">{file.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagsRenderer({ value, className }: { value: string[]; className?: string }) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-muted-foreground italic">No tags</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{value.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="px-2 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// ========================================
|
||||
// TestContextCard Component
|
||||
// ========================================
|
||||
// Displays test context with stats and framework info
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { TestTube, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
|
||||
export interface TestFramework {
|
||||
name?: string;
|
||||
plugins?: string[];
|
||||
}
|
||||
|
||||
export interface FrameworkConfig {
|
||||
backend?: TestFramework;
|
||||
frontend?: TestFramework;
|
||||
}
|
||||
|
||||
export interface TestContextData {
|
||||
frameworks?: FrameworkConfig;
|
||||
existing_tests?: string[];
|
||||
coverage_config?: Record<string, unknown>;
|
||||
test_markers?: string[];
|
||||
}
|
||||
|
||||
export interface TestContextCardProps {
|
||||
data?: TestContextData;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestContextCard component - Displays testing context and frameworks
|
||||
*/
|
||||
export function TestContextCard({ data }: TestContextCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!data || (!data.frameworks && !data.existing_tests?.length && !data.test_markers?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testCount = data.existing_tests?.length || 0;
|
||||
const markerCount = data.test_markers?.length || 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TestTube className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.context.testContext.title' })}
|
||||
{testCount > 0 && (
|
||||
<Badge variant="secondary">{testCount} {formatMessage({ id: 'sessionDetail.context.testContext.tests' })}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Row */}
|
||||
{(testCount > 0 || markerCount > 0) && (
|
||||
<div className="flex gap-4">
|
||||
{testCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-success" />
|
||||
<span className="text-sm text-foreground">
|
||||
{testCount} {formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{markerCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning" />
|
||||
<span className="text-sm text-foreground">
|
||||
{markerCount} {formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Cards */}
|
||||
{data.frameworks && (
|
||||
<FrameworkSection frameworks={data.frameworks} />
|
||||
)}
|
||||
|
||||
{/* Test Markers */}
|
||||
{data.test_markers && data.test_markers.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.context.testContext.markers' })}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.test_markers.map((marker, index) => (
|
||||
<Badge key={index} variant="info" className="px-2 py-0.5">
|
||||
{marker}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Config */}
|
||||
{data.coverage_config && Object.keys(data.coverage_config).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.context.testContext.coverage' })}
|
||||
</h4>
|
||||
<FieldRenderer value={data.coverage_config} type="object" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Tests List */}
|
||||
{data.existing_tests && data.existing_tests.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.context.testContext.existingTests' })}
|
||||
</h4>
|
||||
<FieldRenderer value={data.existing_tests} type="array" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameworkSectionProps {
|
||||
frameworks: FrameworkConfig;
|
||||
}
|
||||
|
||||
function FrameworkSection({ frameworks }: FrameworkSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{frameworks.backend && (
|
||||
<FrameworkCard
|
||||
title={formatMessage({ id: 'sessionDetail.context.testContext.backend' })}
|
||||
framework={frameworks.backend}
|
||||
/>
|
||||
)}
|
||||
{frameworks.frontend && (
|
||||
<FrameworkCard
|
||||
title={formatMessage({ id: 'sessionDetail.context.testContext.frontend' })}
|
||||
framework={frameworks.frontend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameworkCardProps {
|
||||
title: string;
|
||||
framework: TestFramework;
|
||||
}
|
||||
|
||||
function FrameworkCard({ title, framework }: FrameworkCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="p-3 border rounded-lg">
|
||||
<div className="text-sm font-medium text-foreground mb-2">{title}</div>
|
||||
<div className="space-y-1">
|
||||
{framework.name && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.context.testContext.framework' })}: {framework.name}
|
||||
</div>
|
||||
)}
|
||||
{framework.plugins && framework.plugins.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{framework.plugins.map((plugin, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{plugin}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
ccw/frontend/src/components/session-detail/context/index.ts
Normal file
24
ccw/frontend/src/components/session-detail/context/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// ========================================
|
||||
// Context Components Exports
|
||||
// ========================================
|
||||
|
||||
export { FieldRenderer } from './FieldRenderer';
|
||||
export type { FieldRendererProps } from './FieldRenderer';
|
||||
|
||||
export { ExplorationCollapsible } from './ExplorationCollapsible';
|
||||
export type { ExplorationCollapsibleProps } from './ExplorationCollapsible';
|
||||
|
||||
export { ExplorationsSection } from './ExplorationsSection';
|
||||
export type { ExplorationsSectionProps, ExplorationsData } from './ExplorationsSection';
|
||||
|
||||
export { AssetsCard } from './AssetsCard';
|
||||
export type { AssetsCardProps, AssetsData, AssetItem } from './AssetsCard';
|
||||
|
||||
export { DependenciesCard } from './DependenciesCard';
|
||||
export type { DependenciesCardProps, DependenciesData, InternalDependency, ExternalDependency } from './DependenciesCard';
|
||||
|
||||
export { TestContextCard } from './TestContextCard';
|
||||
export type { TestContextCardProps, TestContextData, TestFramework, FrameworkConfig } from './TestContextCard';
|
||||
|
||||
export { ConflictDetectionCard } from './ConflictDetectionCard';
|
||||
export type { ConflictDetectionCardProps, ConflictDetectionData, RiskFactors } from './ConflictDetectionCard';
|
||||
@@ -0,0 +1,46 @@
|
||||
// ========================================
|
||||
// BulkActionButton Component
|
||||
// ========================================
|
||||
// Reusable button component for bulk actions
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button, ButtonProps } from '@/components/ui/Button';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface BulkActionButtonProps extends Omit<ButtonProps, 'leftIcon'> {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BulkActionButton component - Button with icon for bulk actions
|
||||
*/
|
||||
export function BulkActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
variant = 'default',
|
||||
size = 'sm',
|
||||
className = '',
|
||||
...props
|
||||
}: BulkActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled || isLoading}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// ========================================
|
||||
// TaskStatsBar Component
|
||||
// ========================================
|
||||
// Statistics bar with bulk action buttons for tasks
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CheckCircle, Loader2, Circle } from 'lucide-react';
|
||||
import { BulkActionButton } from './BulkActionButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TaskStatsBarProps {
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
onMarkAllPending?: () => void | Promise<void>;
|
||||
onMarkAllInProgress?: () => void | Promise<void>;
|
||||
onMarkAllCompleted?: () => void | Promise<void>;
|
||||
isLoadingPending?: boolean;
|
||||
isLoadingInProgress?: boolean;
|
||||
isLoadingCompleted?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskStatsBar component - Display task statistics with bulk action buttons
|
||||
*/
|
||||
export function TaskStatsBar({
|
||||
completed,
|
||||
inProgress,
|
||||
pending,
|
||||
onMarkAllPending,
|
||||
onMarkAllInProgress,
|
||||
onMarkAllCompleted,
|
||||
isLoadingPending = false,
|
||||
isLoadingInProgress = false,
|
||||
isLoadingCompleted = false,
|
||||
className = '',
|
||||
}: TaskStatsBarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border', className)}>
|
||||
{/* Statistics */}
|
||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Loader2 className="h-4 w-4 text-warning" />
|
||||
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{onMarkAllPending && (
|
||||
<BulkActionButton
|
||||
icon={Circle}
|
||||
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllPending' })}
|
||||
onClick={onMarkAllPending}
|
||||
isLoading={isLoadingPending}
|
||||
disabled={pending === 0}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
{onMarkAllInProgress && (
|
||||
<BulkActionButton
|
||||
icon={Loader2}
|
||||
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllInProgress' })}
|
||||
onClick={onMarkAllInProgress}
|
||||
isLoading={isLoadingInProgress}
|
||||
disabled={inProgress === 0}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
{onMarkAllCompleted && (
|
||||
<BulkActionButton
|
||||
icon={CheckCircle}
|
||||
label={formatMessage({ id: 'sessionDetail.tasks.quickActions.markAllCompleted' })}
|
||||
onClick={onMarkAllCompleted}
|
||||
isLoading={isLoadingCompleted}
|
||||
disabled={completed === 0}
|
||||
variant="outline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// ========================================
|
||||
// TaskStatusDropdown Component
|
||||
// ========================================
|
||||
// Inline status dropdown for task items
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Circle,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
CircleX,
|
||||
Forward,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { TaskStatus } from '@/lib/api';
|
||||
|
||||
export interface TaskStatusDropdownProps {
|
||||
currentStatus: TaskStatus;
|
||||
onStatusChange: (newStatus: TaskStatus) => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'default';
|
||||
}
|
||||
|
||||
// Status configuration
|
||||
const statusConfig: Record<
|
||||
TaskStatus,
|
||||
{
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: 'sessionDetail.tasks.status.pending',
|
||||
variant: 'secondary',
|
||||
icon: Circle,
|
||||
},
|
||||
in_progress: {
|
||||
label: 'sessionDetail.tasks.status.inProgress',
|
||||
variant: 'warning',
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: 'sessionDetail.tasks.status.completed',
|
||||
variant: 'success',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
blocked: {
|
||||
label: 'sessionDetail.tasks.status.blocked',
|
||||
variant: 'destructive',
|
||||
icon: CircleX,
|
||||
},
|
||||
skipped: {
|
||||
label: 'sessionDetail.tasks.status.skipped',
|
||||
variant: 'default',
|
||||
icon: Forward,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* TaskStatusDropdown component - Inline status selector with optimistic UI
|
||||
*/
|
||||
export function TaskStatusDropdown({
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
disabled = false,
|
||||
size = 'sm',
|
||||
}: TaskStatusDropdownProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
|
||||
const handleStatusChange = async (newStatus: TaskStatus) => {
|
||||
if (newStatus === currentStatus || isChanging) return;
|
||||
|
||||
setIsChanging(true);
|
||||
try {
|
||||
await onStatusChange(newStatus);
|
||||
} catch (error) {
|
||||
console.error('[TaskStatusDropdown] Failed to update status:', error);
|
||||
} finally {
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentConfig = statusConfig[currentStatus] || statusConfig.pending;
|
||||
const StatusIcon = currentConfig.icon;
|
||||
const badgeSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={disabled || isChanging}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge
|
||||
variant={currentConfig.variant}
|
||||
className={`gap-1 ${badgeSize} ${isChanging ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{isChanging ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
)}
|
||||
{formatMessage({ id: currentConfig.label })}
|
||||
</Badge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[160px]">
|
||||
{(Object.keys(statusConfig) as TaskStatus[]).map((status) => {
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
disabled={status === currentStatus || isChanging}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{formatMessage({ id: config.label })}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
12
ccw/frontend/src/components/session-detail/tasks/index.ts
Normal file
12
ccw/frontend/src/components/session-detail/tasks/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// ========================================
|
||||
// Task Components Index
|
||||
// ========================================
|
||||
// Exports for session-detail task components
|
||||
|
||||
export { BulkActionButton } from './BulkActionButton';
|
||||
export { TaskStatsBar } from './TaskStatsBar';
|
||||
export { TaskStatusDropdown } from './TaskStatusDropdown';
|
||||
|
||||
export type { BulkActionButtonProps } from './BulkActionButton';
|
||||
export type { TaskStatsBarProps } from './TaskStatsBar';
|
||||
export type { TaskStatusDropdownProps } from './TaskStatusDropdown';
|
||||
245
ccw/frontend/src/components/shared/MarkdownModal.tsx
Normal file
245
ccw/frontend/src/components/shared/MarkdownModal.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// ========================================
|
||||
// MarkdownModal Component
|
||||
// ========================================
|
||||
// Modal for viewing markdown, JSON, or text content with copy and download actions
|
||||
|
||||
import * as React from 'react';
|
||||
import { FileText, Copy, Download, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export type ContentType = 'markdown' | 'json' | 'text';
|
||||
|
||||
export interface MarkdownModalProps {
|
||||
/** Whether the modal is open */
|
||||
isOpen: boolean;
|
||||
/** Called when modal is closed */
|
||||
onClose: () => void;
|
||||
/** Title displayed in modal header */
|
||||
title: string;
|
||||
/** Content to display */
|
||||
content: string;
|
||||
/** Type of content for appropriate rendering */
|
||||
contentType?: ContentType;
|
||||
/** Maximum width of the modal */
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
/** Maximum height of content area */
|
||||
maxHeight?: string;
|
||||
/** Optional custom actions */
|
||||
actions?: ModalAction[];
|
||||
/** Whether content is loading */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface ModalAction {
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
onClick: (content: string) => void | Promise<void>;
|
||||
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'success';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Component
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Modal for viewing markdown, JSON, or text content
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MarkdownModal
|
||||
* isOpen={isOpen}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* title="IMPL_PLAN.md"
|
||||
* content={implPlanContent}
|
||||
* contentType="markdown"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function MarkdownModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
content,
|
||||
contentType = 'markdown',
|
||||
maxWidth = '2xl',
|
||||
maxHeight = '60vh',
|
||||
actions,
|
||||
isLoading = false,
|
||||
}: MarkdownModalProps) {
|
||||
const { success, error } = useNotifications();
|
||||
const [isCopying, setIsCopying] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
success('Copied', 'Content copied to clipboard');
|
||||
} catch (err) {
|
||||
error('Error', 'Failed to copy content');
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const extension = contentType === 'json' ? 'json' : 'md';
|
||||
a.download = `${title.replace(/[^a-z0-9]/gi, '-')}.${extension}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
success('Downloaded', `File ${title} downloaded`);
|
||||
} catch (err) {
|
||||
error('Error', 'Failed to download content');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultActions: ModalAction[] = [
|
||||
{
|
||||
label: 'Copy',
|
||||
icon: Copy,
|
||||
onClick: handleCopy,
|
||||
variant: 'outline',
|
||||
disabled: isCopying || isLoading || !content,
|
||||
},
|
||||
{
|
||||
label: 'Download',
|
||||
icon: Download,
|
||||
onClick: handleDownload,
|
||||
variant: 'outline',
|
||||
disabled: isDownloading || isLoading || !content,
|
||||
},
|
||||
];
|
||||
|
||||
const modalActions = actions || defaultActions;
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<div>
|
||||
<p className="text-muted-foreground">No content available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (contentType) {
|
||||
case 'markdown':
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
case 'json':
|
||||
return (
|
||||
<pre className="text-sm bg-muted p-4 rounded-lg overflow-x-auto font-mono">
|
||||
{JSON.stringify(JSON.parse(content), null, 2)}
|
||||
</pre>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<pre className="text-sm whitespace-pre-wrap break-words font-sans leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
default:
|
||||
return <pre className="text-sm">{content}</pre>;
|
||||
}
|
||||
};
|
||||
|
||||
const maxWidthClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className={cn('flex flex-col', maxWidthClass)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto py-4"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{modalActions.map((action, index) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
onClick={() => action.onClick(content)}
|
||||
disabled={action.disabled || isLoading}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{action.label}
|
||||
{(isCopying && action.label === 'Copy') && (
|
||||
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
|
||||
)}
|
||||
{(isDownloading && action.label === 'Download') && (
|
||||
<Loader2 className="w-4 h-4 ml-2 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button variant="ghost" onClick={onClose} disabled={isLoading}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exports
|
||||
// ========================================
|
||||
|
||||
export default MarkdownModal;
|
||||
@@ -9,6 +9,9 @@ export type { ButtonProps } from "./Button";
|
||||
export { Input } from "./Input";
|
||||
export type { InputProps } from "./Input";
|
||||
|
||||
// Checkbox
|
||||
export { Checkbox } from "./Checkbox";
|
||||
|
||||
// Select (Radix)
|
||||
export {
|
||||
Select,
|
||||
|
||||
@@ -195,4 +195,55 @@ export {
|
||||
} from './useWorkspaceQueryKeys';
|
||||
export type {
|
||||
WorkspaceQueryKeys,
|
||||
} from './useWorkspaceQueryKeys';
|
||||
} from './useWorkspaceQueryKeys';
|
||||
|
||||
// ========== CodexLens ==========
|
||||
export {
|
||||
useCodexLensDashboard,
|
||||
useCodexLensStatus,
|
||||
useCodexLensWorkspaceStatus,
|
||||
useCodexLensConfig,
|
||||
useCodexLensModels,
|
||||
useCodexLensModelInfo,
|
||||
useCodexLensEnv,
|
||||
useCodexLensGpu,
|
||||
useCodexLensIgnorePatterns,
|
||||
useUpdateCodexLensConfig,
|
||||
useBootstrapCodexLens,
|
||||
useUninstallCodexLens,
|
||||
useDownloadModel,
|
||||
useDeleteModel,
|
||||
useUpdateCodexLensEnv,
|
||||
useSelectGpu,
|
||||
useUpdateIgnorePatterns,
|
||||
useCodexLensMutations,
|
||||
codexLensKeys,
|
||||
} from './useCodexLens';
|
||||
export type {
|
||||
UseCodexLensDashboardOptions,
|
||||
UseCodexLensDashboardReturn,
|
||||
UseCodexLensStatusOptions,
|
||||
UseCodexLensStatusReturn,
|
||||
UseCodexLensWorkspaceStatusOptions,
|
||||
UseCodexLensWorkspaceStatusReturn,
|
||||
UseCodexLensConfigOptions,
|
||||
UseCodexLensConfigReturn,
|
||||
UseCodexLensModelsOptions,
|
||||
UseCodexLensModelsReturn,
|
||||
UseCodexLensModelInfoOptions,
|
||||
UseCodexLensModelInfoReturn,
|
||||
UseCodexLensEnvOptions,
|
||||
UseCodexLensEnvReturn,
|
||||
UseCodexLensGpuOptions,
|
||||
UseCodexLensGpuReturn,
|
||||
UseCodexLensIgnorePatternsOptions,
|
||||
UseCodexLensIgnorePatternsReturn,
|
||||
UseUpdateCodexLensConfigReturn,
|
||||
UseBootstrapCodexLensReturn,
|
||||
UseUninstallCodexLensReturn,
|
||||
UseDownloadModelReturn,
|
||||
UseDeleteModelReturn,
|
||||
UseUpdateCodexLensEnvReturn,
|
||||
UseSelectGpuReturn,
|
||||
UseUpdateIgnorePatternsReturn,
|
||||
} from './useCodexLens';
|
||||
@@ -388,13 +388,11 @@ export function useRules(options: UseRulesOptions = {}): UseRulesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: workspaceQueryKeys.rulesList(projectPath),
|
||||
queryFn: () => fetchRules(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
enabled: enabled, // Remove projectPath requirement
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
427
ccw/frontend/src/hooks/useCodexLens.test.tsx
Normal file
427
ccw/frontend/src/hooks/useCodexLens.test.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
// ========================================
|
||||
// useCodexLens Hook Tests
|
||||
// ========================================
|
||||
// Tests for all CodexLens TanStack Query hooks
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as api from '../lib/api';
|
||||
import {
|
||||
useCodexLensDashboard,
|
||||
useCodexLensStatus,
|
||||
useCodexLensConfig,
|
||||
useCodexLensModels,
|
||||
useCodexLensEnv,
|
||||
useCodexLensGpu,
|
||||
useUpdateCodexLensConfig,
|
||||
useBootstrapCodexLens,
|
||||
useUninstallCodexLens,
|
||||
useDownloadModel,
|
||||
useDeleteModel,
|
||||
useUpdateCodexLensEnv,
|
||||
useSelectGpu,
|
||||
} from './useCodexLens';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('../lib/api', () => ({
|
||||
fetchCodexLensDashboardInit: vi.fn(),
|
||||
fetchCodexLensStatus: vi.fn(),
|
||||
fetchCodexLensConfig: vi.fn(),
|
||||
updateCodexLensConfig: vi.fn(),
|
||||
bootstrapCodexLens: vi.fn(),
|
||||
uninstallCodexLens: vi.fn(),
|
||||
fetchCodexLensModels: vi.fn(),
|
||||
fetchCodexLensModelInfo: vi.fn(),
|
||||
downloadCodexLensModel: vi.fn(),
|
||||
downloadCodexLensCustomModel: vi.fn(),
|
||||
deleteCodexLensModel: vi.fn(),
|
||||
deleteCodexLensModelByPath: vi.fn(),
|
||||
fetchCodexLensEnv: vi.fn(),
|
||||
updateCodexLensEnv: vi.fn(),
|
||||
fetchCodexLensGpuDetect: vi.fn(),
|
||||
fetchCodexLensGpuList: vi.fn(),
|
||||
selectCodexLensGpu: vi.fn(),
|
||||
resetCodexLensGpu: vi.fn(),
|
||||
fetchCodexLensIgnorePatterns: vi.fn(),
|
||||
updateCodexLensIgnorePatterns: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workflowStore
|
||||
vi.mock('../stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => () => '/test/project'),
|
||||
selectProjectPath: vi.fn(() => '/test/project'),
|
||||
}));
|
||||
|
||||
const mockDashboardData = {
|
||||
installed: true,
|
||||
status: {
|
||||
ready: true,
|
||||
installed: true,
|
||||
version: '1.0.0',
|
||||
pythonVersion: '3.11.0',
|
||||
venvPath: '/path/to/venv',
|
||||
},
|
||||
config: {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
},
|
||||
semantic: { available: true },
|
||||
};
|
||||
|
||||
const mockModelsData = {
|
||||
models: [
|
||||
{
|
||||
profile: 'model1',
|
||||
name: 'Embedding Model 1',
|
||||
type: 'embedding',
|
||||
backend: 'onnx',
|
||||
installed: true,
|
||||
cache_path: '/path/to/cache1',
|
||||
},
|
||||
{
|
||||
profile: 'model2',
|
||||
name: 'Reranker Model 1',
|
||||
type: 'reranker',
|
||||
backend: 'onnx',
|
||||
installed: false,
|
||||
cache_path: '/path/to/cache2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe('useCodexLens Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useCodexLensDashboard', () => {
|
||||
it('should fetch dashboard data', async () => {
|
||||
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledOnce();
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.status?.ready).toBe(true);
|
||||
expect(result.current.config?.index_dir).toBe('~/.codexlens/indexes');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
vi.mocked(api.fetchCodexLensDashboardInit).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBeTruthy();
|
||||
expect(result.current.error?.message).toBe('API Error');
|
||||
});
|
||||
|
||||
it('should be disabled when enabled is false', async () => {
|
||||
const { result } = renderHook(() => useCodexLensDashboard({ enabled: false }), { wrapper });
|
||||
|
||||
expect(api.fetchCodexLensDashboardInit).not.toHaveBeenCalled();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCodexLensStatus', () => {
|
||||
it('should fetch status data', async () => {
|
||||
const mockStatus = { ready: true, installed: true, version: '1.0.0' };
|
||||
vi.mocked(api.fetchCodexLensStatus).mockResolvedValue(mockStatus);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensStatus(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensStatus).toHaveBeenCalledOnce();
|
||||
expect(result.current.ready).toBe(true);
|
||||
expect(result.current.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCodexLensConfig', () => {
|
||||
it('should fetch config data', async () => {
|
||||
const mockConfig = {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
};
|
||||
vi.mocked(api.fetchCodexLensConfig).mockResolvedValue(mockConfig);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensConfig).toHaveBeenCalledOnce();
|
||||
expect(result.current.indexDir).toBe('~/.codexlens/indexes');
|
||||
expect(result.current.indexCount).toBe(100);
|
||||
expect(result.current.apiMaxWorkers).toBe(4);
|
||||
expect(result.current.apiBatchSize).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCodexLensModels', () => {
|
||||
it('should fetch and filter models by type', async () => {
|
||||
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensModels(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(result.current.models).toHaveLength(2);
|
||||
expect(result.current.embeddingModels).toHaveLength(1);
|
||||
expect(result.current.rerankerModels).toHaveLength(1);
|
||||
expect(result.current.embeddingModels?.[0].type).toBe('embedding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCodexLensEnv', () => {
|
||||
it('should fetch environment variables', async () => {
|
||||
const mockEnv = {
|
||||
env: { KEY1: 'value1', KEY2: 'value2' },
|
||||
settings: { SETTING1: 'setting1' },
|
||||
raw: 'KEY1=value1\nKEY2=value2',
|
||||
};
|
||||
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensEnv(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensEnv).toHaveBeenCalledOnce();
|
||||
expect(result.current.env).toEqual({ KEY1: 'value1', KEY2: 'value2' });
|
||||
expect(result.current.settings).toEqual({ SETTING1: 'setting1' });
|
||||
expect(result.current.raw).toBe('KEY1=value1\nKEY2=value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCodexLensGpu', () => {
|
||||
it('should fetch GPU detect and list data', async () => {
|
||||
const mockDetect = { supported: true, has_cuda: true };
|
||||
const mockList = {
|
||||
devices: [
|
||||
{ id: 0, name: 'GPU 0', type: 'cuda', driver: '12.0', memory: '8GB' },
|
||||
],
|
||||
selected_device_id: 0,
|
||||
};
|
||||
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect);
|
||||
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensGpu(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoadingDetect).toBe(false));
|
||||
await waitFor(() => expect(result.current.isLoadingList).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensGpuDetect).toHaveBeenCalledOnce();
|
||||
expect(api.fetchCodexLensGpuList).toHaveBeenCalledOnce();
|
||||
expect(result.current.supported).toBe(true);
|
||||
expect(result.current.devices).toHaveLength(1);
|
||||
expect(result.current.selectedDeviceId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateCodexLensConfig', () => {
|
||||
it('should update config and invalidate queries', async () => {
|
||||
vi.mocked(api.updateCodexLensConfig).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Config updated',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateCodexLensConfig(), { wrapper });
|
||||
|
||||
const updateResult = await result.current.updateConfig({
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
api_max_workers: 8,
|
||||
api_batch_size: 16,
|
||||
});
|
||||
|
||||
expect(api.updateCodexLensConfig).toHaveBeenCalledWith({
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
api_max_workers: 8,
|
||||
api_batch_size: 16,
|
||||
});
|
||||
expect(updateResult.success).toBe(true);
|
||||
expect(updateResult.message).toBe('Config updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBootstrapCodexLens', () => {
|
||||
it('should bootstrap CodexLens and invalidate queries', async () => {
|
||||
vi.mocked(api.bootstrapCodexLens).mockResolvedValue({
|
||||
success: true,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBootstrapCodexLens(), { wrapper });
|
||||
|
||||
const bootstrapResult = await result.current.bootstrap();
|
||||
|
||||
expect(api.bootstrapCodexLens).toHaveBeenCalledOnce();
|
||||
expect(bootstrapResult.success).toBe(true);
|
||||
expect(bootstrapResult.version).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUninstallCodexLens', () => {
|
||||
it('should uninstall CodexLens and invalidate queries', async () => {
|
||||
vi.mocked(api.uninstallCodexLens).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'CodexLens uninstalled',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUninstallCodexLens(), { wrapper });
|
||||
|
||||
const uninstallResult = await result.current.uninstall();
|
||||
|
||||
expect(api.uninstallCodexLens).toHaveBeenCalledOnce();
|
||||
expect(uninstallResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDownloadModel', () => {
|
||||
it('should download model by profile', async () => {
|
||||
vi.mocked(api.downloadCodexLensModel).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Model downloaded',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDownloadModel(), { wrapper });
|
||||
|
||||
const downloadResult = await result.current.downloadModel('model1');
|
||||
|
||||
expect(api.downloadCodexLensModel).toHaveBeenCalledWith('model1');
|
||||
expect(downloadResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should download custom model', async () => {
|
||||
vi.mocked(api.downloadCodexLensCustomModel).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Custom model downloaded',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDownloadModel(), { wrapper });
|
||||
|
||||
const downloadResult = await result.current.downloadCustomModel('custom/model', 'embedding');
|
||||
|
||||
expect(api.downloadCodexLensCustomModel).toHaveBeenCalledWith('custom/model', 'embedding');
|
||||
expect(downloadResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteModel', () => {
|
||||
it('should delete model by profile', async () => {
|
||||
vi.mocked(api.deleteCodexLensModel).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Model deleted',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDeleteModel(), { wrapper });
|
||||
|
||||
const deleteResult = await result.current.deleteModel('model1');
|
||||
|
||||
expect(api.deleteCodexLensModel).toHaveBeenCalledWith('model1');
|
||||
expect(deleteResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete model by path', async () => {
|
||||
vi.mocked(api.deleteCodexLensModelByPath).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Model deleted',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDeleteModel(), { wrapper });
|
||||
|
||||
const deleteResult = await result.current.deleteModelByPath('/path/to/model');
|
||||
|
||||
expect(api.deleteCodexLensModelByPath).toHaveBeenCalledWith('/path/to/model');
|
||||
expect(deleteResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateCodexLensEnv', () => {
|
||||
it('should update environment variables', async () => {
|
||||
vi.mocked(api.updateCodexLensEnv).mockResolvedValue({
|
||||
success: true,
|
||||
env: { KEY1: 'newvalue' },
|
||||
settings: {},
|
||||
raw: 'KEY1=newvalue',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateCodexLensEnv(), { wrapper });
|
||||
|
||||
const updateResult = await result.current.updateEnv({
|
||||
raw: 'KEY1=newvalue',
|
||||
});
|
||||
|
||||
expect(api.updateCodexLensEnv).toHaveBeenCalledWith({ raw: 'KEY1=newvalue' });
|
||||
expect(updateResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelectGpu', () => {
|
||||
it('should select GPU', async () => {
|
||||
vi.mocked(api.selectCodexLensGpu).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'GPU selected',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSelectGpu(), { wrapper });
|
||||
|
||||
const selectResult = await result.current.selectGpu(0);
|
||||
|
||||
expect(api.selectCodexLensGpu).toHaveBeenCalledWith(0);
|
||||
expect(selectResult.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset GPU', async () => {
|
||||
vi.mocked(api.resetCodexLensGpu).mockResolvedValue({
|
||||
success: true,
|
||||
message: 'GPU reset',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSelectGpu(), { wrapper });
|
||||
|
||||
const resetResult = await result.current.resetGpu();
|
||||
|
||||
expect(api.resetCodexLensGpu).toHaveBeenCalledOnce();
|
||||
expect(resetResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query refetch', () => {
|
||||
it('should refetch dashboard data', async () => {
|
||||
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
|
||||
|
||||
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(1);
|
||||
|
||||
await result.current.refetch();
|
||||
|
||||
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
762
ccw/frontend/src/hooks/useCodexLens.ts
Normal file
762
ccw/frontend/src/hooks/useCodexLens.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
// ========================================
|
||||
// useCodexLens Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for CodexLens management
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchCodexLensDashboardInit,
|
||||
fetchCodexLensStatus,
|
||||
fetchCodexLensWorkspaceStatus,
|
||||
fetchCodexLensConfig,
|
||||
updateCodexLensConfig,
|
||||
bootstrapCodexLens,
|
||||
uninstallCodexLens,
|
||||
fetchCodexLensModels,
|
||||
fetchCodexLensModelInfo,
|
||||
downloadCodexLensModel,
|
||||
downloadCodexLensCustomModel,
|
||||
deleteCodexLensModel,
|
||||
deleteCodexLensModelByPath,
|
||||
fetchCodexLensEnv,
|
||||
updateCodexLensEnv,
|
||||
fetchCodexLensGpuDetect,
|
||||
fetchCodexLensGpuList,
|
||||
selectCodexLensGpu,
|
||||
resetCodexLensGpu,
|
||||
fetchCodexLensIgnorePatterns,
|
||||
updateCodexLensIgnorePatterns,
|
||||
type CodexLensDashboardInitResponse,
|
||||
type CodexLensVenvStatus,
|
||||
type CodexLensConfig,
|
||||
type CodexLensModelsResponse,
|
||||
type CodexLensModelInfoResponse,
|
||||
type CodexLensEnvResponse,
|
||||
type CodexLensUpdateEnvResponse,
|
||||
type CodexLensGpuDetectResponse,
|
||||
type CodexLensGpuListResponse,
|
||||
type CodexLensIgnorePatternsResponse,
|
||||
type CodexLensUpdateEnvRequest,
|
||||
type CodexLensUpdateIgnorePatternsRequest,
|
||||
type CodexLensWorkspaceStatus,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// Query key factory
|
||||
export const codexLensKeys = {
|
||||
all: ['codexLens'] as const,
|
||||
dashboard: () => [...codexLensKeys.all, 'dashboard'] as const,
|
||||
status: () => [...codexLensKeys.all, 'status'] as const,
|
||||
workspace: (path?: string) => [...codexLensKeys.all, 'workspace', path] as const,
|
||||
config: () => [...codexLensKeys.all, 'config'] as const,
|
||||
models: () => [...codexLensKeys.all, 'models'] as const,
|
||||
modelInfo: (profile: string) => [...codexLensKeys.models(), 'info', profile] as const,
|
||||
env: () => [...codexLensKeys.all, 'env'] as const,
|
||||
gpu: () => [...codexLensKeys.all, 'gpu'] as const,
|
||||
gpuList: () => [...codexLensKeys.gpu(), 'list'] as const,
|
||||
gpuDetect: () => [...codexLensKeys.gpu(), 'detect'] as const,
|
||||
ignorePatterns: () => [...codexLensKeys.all, 'ignorePatterns'] as const,
|
||||
};
|
||||
|
||||
// Default stale times
|
||||
const STALE_TIME_SHORT = 30 * 1000; // 30 seconds for frequently changing data
|
||||
const STALE_TIME_MEDIUM = 2 * 60 * 1000; // 2 minutes for moderately changing data
|
||||
const STALE_TIME_LONG = 10 * 60 * 1000; // 10 minutes for rarely changing data
|
||||
|
||||
// ========== Query Hooks ==========
|
||||
|
||||
export interface UseCodexLensDashboardOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensDashboardReturn {
|
||||
data: CodexLensDashboardInitResponse | undefined;
|
||||
installed: boolean;
|
||||
status: CodexLensVenvStatus | undefined;
|
||||
config: CodexLensConfig | undefined;
|
||||
semantic: { available: boolean } | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens dashboard initialization data
|
||||
*/
|
||||
export function useCodexLensDashboard(options: UseCodexLensDashboardOptions = {}): UseCodexLensDashboardReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_SHORT } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.dashboard(),
|
||||
queryFn: fetchCodexLensDashboardInit,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
installed: query.data?.installed ?? false,
|
||||
status: query.data?.status,
|
||||
config: query.data?.config,
|
||||
semantic: query.data?.semantic,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensStatusOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensStatusReturn {
|
||||
status: CodexLensVenvStatus | undefined;
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens venv status
|
||||
*/
|
||||
export function useCodexLensStatus(options: UseCodexLensStatusOptions = {}): UseCodexLensStatusReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_SHORT } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.status(),
|
||||
queryFn: fetchCodexLensStatus,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
status: query.data,
|
||||
ready: query.data?.ready ?? false,
|
||||
installed: query.data?.installed ?? false,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensWorkspaceStatusOptions {
|
||||
projectPath?: string;
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensWorkspaceStatusReturn {
|
||||
data: CodexLensWorkspaceStatus | undefined;
|
||||
hasIndex: boolean;
|
||||
ftsPercent: number;
|
||||
vectorPercent: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens workspace index status
|
||||
*/
|
||||
export function useCodexLensWorkspaceStatus(options: UseCodexLensWorkspaceStatusOptions = {}): UseCodexLensWorkspaceStatusReturn {
|
||||
const { projectPath, enabled = true, staleTime = STALE_TIME_SHORT } = options;
|
||||
const projectPathFromStore = useWorkflowStore(selectProjectPath);
|
||||
const actualProjectPath = projectPath ?? projectPathFromStore;
|
||||
const queryEnabled = enabled && !!actualProjectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.workspace(actualProjectPath),
|
||||
queryFn: () => fetchCodexLensWorkspaceStatus(actualProjectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
hasIndex: query.data?.hasIndex ?? false,
|
||||
ftsPercent: query.data?.fts.percent ?? 0,
|
||||
vectorPercent: query.data?.vector.percent ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensConfigOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensConfigReturn {
|
||||
config: CodexLensConfig | undefined;
|
||||
indexDir: string;
|
||||
indexCount: number;
|
||||
apiMaxWorkers: number;
|
||||
apiBatchSize: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens configuration
|
||||
*/
|
||||
export function useCodexLensConfig(options: UseCodexLensConfigOptions = {}): UseCodexLensConfigReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.config(),
|
||||
queryFn: fetchCodexLensConfig,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
config: query.data,
|
||||
indexDir: query.data?.index_dir ?? '~/.codexlens/indexes',
|
||||
indexCount: query.data?.index_count ?? 0,
|
||||
apiMaxWorkers: query.data?.api_max_workers ?? 4,
|
||||
apiBatchSize: query.data?.api_batch_size ?? 8,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensModelsOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensModelsReturn {
|
||||
models: CodexLensModelsResponse['models'] | undefined;
|
||||
embeddingModels: CodexLensModelsResponse['models'] | undefined;
|
||||
rerankerModels: CodexLensModelsResponse['models'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens models list
|
||||
*/
|
||||
export function useCodexLensModels(options: UseCodexLensModelsOptions = {}): UseCodexLensModelsReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.models(),
|
||||
queryFn: fetchCodexLensModels,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const models = query.data?.models ?? [];
|
||||
const embeddingModels = models?.filter(m => m.type === 'embedding');
|
||||
const rerankerModels = models?.filter(m => m.type === 'reranker');
|
||||
|
||||
return {
|
||||
models,
|
||||
embeddingModels,
|
||||
rerankerModels,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensModelInfoOptions {
|
||||
profile: string;
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensModelInfoReturn {
|
||||
info: CodexLensModelInfoResponse['info'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens model info by profile
|
||||
*/
|
||||
export function useCodexLensModelInfo(options: UseCodexLensModelInfoOptions): UseCodexLensModelInfoReturn {
|
||||
const { profile, enabled = true, staleTime = STALE_TIME_LONG } = options;
|
||||
const queryEnabled = enabled && !!profile;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.modelInfo(profile),
|
||||
queryFn: () => fetchCodexLensModelInfo(profile),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
info: query.data?.info,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensEnvOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensEnvReturn {
|
||||
data: CodexLensEnvResponse | undefined;
|
||||
env: Record<string, string> | undefined;
|
||||
settings: Record<string, string> | undefined;
|
||||
raw: string | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens environment variables
|
||||
*/
|
||||
export function useCodexLensEnv(options: UseCodexLensEnvOptions = {}): UseCodexLensEnvReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.env(),
|
||||
queryFn: fetchCodexLensEnv,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
env: query.data?.env,
|
||||
settings: query.data?.settings,
|
||||
raw: query.data?.raw,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensGpuOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensGpuReturn {
|
||||
detectData: CodexLensGpuDetectResponse | undefined;
|
||||
listData: CodexLensGpuListResponse | undefined;
|
||||
supported: boolean;
|
||||
devices: CodexLensGpuListResponse['devices'] | undefined;
|
||||
selectedDeviceId: string | number | undefined;
|
||||
isLoadingDetect: boolean;
|
||||
isLoadingList: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens GPU information
|
||||
* Combines both detect and list queries
|
||||
*/
|
||||
export function useCodexLensGpu(options: UseCodexLensGpuOptions = {}): UseCodexLensGpuReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_LONG } = options;
|
||||
|
||||
const detectQuery = useQuery({
|
||||
queryKey: codexLensKeys.gpuDetect(),
|
||||
queryFn: fetchCodexLensGpuDetect,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: codexLensKeys.gpuList(),
|
||||
queryFn: fetchCodexLensGpuList,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await Promise.all([detectQuery.refetch(), listQuery.refetch()]);
|
||||
};
|
||||
|
||||
return {
|
||||
detectData: detectQuery.data,
|
||||
listData: listQuery.data,
|
||||
supported: detectQuery.data?.supported ?? false,
|
||||
devices: listQuery.data?.devices,
|
||||
selectedDeviceId: listQuery.data?.selected_device_id,
|
||||
isLoadingDetect: detectQuery.isLoading,
|
||||
isLoadingList: listQuery.isLoading,
|
||||
error: detectQuery.error || listQuery.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCodexLensIgnorePatternsOptions {
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export interface UseCodexLensIgnorePatternsReturn {
|
||||
data: CodexLensIgnorePatternsResponse | undefined;
|
||||
patterns: string[] | undefined;
|
||||
extensionFilters: string[] | undefined;
|
||||
defaults: CodexLensIgnorePatternsResponse['defaults'] | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching CodexLens ignore patterns
|
||||
*/
|
||||
export function useCodexLensIgnorePatterns(options: UseCodexLensIgnorePatternsOptions = {}): UseCodexLensIgnorePatternsReturn {
|
||||
const { enabled = true, staleTime = STALE_TIME_LONG } = options;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: codexLensKeys.ignorePatterns(),
|
||||
queryFn: fetchCodexLensIgnorePatterns,
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
patterns: query.data?.patterns,
|
||||
extensionFilters: query.data?.extensionFilters,
|
||||
defaults: query.data?.defaults,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Mutation Hooks ==========
|
||||
|
||||
export interface UseUpdateCodexLensConfigReturn {
|
||||
updateConfig: (config: { index_dir: string; api_max_workers?: number; api_batch_size?: number }) => Promise<{ success: boolean; message?: string }>;
|
||||
isUpdating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating CodexLens configuration
|
||||
*/
|
||||
export function useUpdateCodexLensConfig(): UseUpdateCodexLensConfigReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateCodexLensConfig,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.config() });
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateConfig: mutation.mutateAsync,
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBootstrapCodexLensReturn {
|
||||
bootstrap: () => Promise<{ success: boolean; message?: string; version?: string }>;
|
||||
isBootstrapping: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for bootstrapping/installing CodexLens
|
||||
*/
|
||||
export function useBootstrapCodexLens(): UseBootstrapCodexLensReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: bootstrapCodexLens,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bootstrap: mutation.mutateAsync,
|
||||
isBootstrapping: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseUninstallCodexLensReturn {
|
||||
uninstall: () => Promise<{ success: boolean; message?: string }>;
|
||||
isUninstalling: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for uninstalling CodexLens
|
||||
*/
|
||||
export function useUninstallCodexLens(): UseUninstallCodexLensReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: uninstallCodexLens,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
uninstall: mutation.mutateAsync,
|
||||
isUninstalling: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseDownloadModelReturn {
|
||||
downloadModel: (profile: string) => Promise<{ success: boolean; message?: string }>;
|
||||
downloadCustomModel: (modelName: string, modelType?: string) => Promise<{ success: boolean; message?: string }>;
|
||||
isDownloading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for downloading CodexLens models
|
||||
*/
|
||||
export function useDownloadModel(): UseDownloadModelReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({ profile, modelName, modelType }: { profile?: string; modelName?: string; modelType?: string }) => {
|
||||
if (profile) return downloadCodexLensModel(profile);
|
||||
if (modelName) return downloadCodexLensCustomModel(modelName, modelType);
|
||||
throw new Error('Either profile or modelName must be provided');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.models() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
downloadModel: (profile) => mutation.mutateAsync({ profile }),
|
||||
downloadCustomModel: (modelName, modelType) => mutation.mutateAsync({ modelName, modelType }),
|
||||
isDownloading: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseDeleteModelReturn {
|
||||
deleteModel: (profile: string) => Promise<{ success: boolean; message?: string }>;
|
||||
deleteModelByPath: (cachePath: string) => Promise<{ success: boolean; message?: string }>;
|
||||
isDeleting: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for deleting CodexLens models
|
||||
*/
|
||||
export function useDeleteModel(): UseDeleteModelReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({ profile, cachePath }: { profile?: string; cachePath?: string }) => {
|
||||
if (profile) return deleteCodexLensModel(profile);
|
||||
if (cachePath) return deleteCodexLensModelByPath(cachePath);
|
||||
throw new Error('Either profile or cachePath must be provided');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.models() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteModel: (profile) => mutation.mutateAsync({ profile }),
|
||||
deleteModelByPath: (cachePath) => mutation.mutateAsync({ cachePath }),
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseUpdateCodexLensEnvReturn {
|
||||
updateEnv: (request: CodexLensUpdateEnvRequest) => Promise<CodexLensUpdateEnvResponse>;
|
||||
isUpdating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating CodexLens environment variables
|
||||
*/
|
||||
export function useUpdateCodexLensEnv(): UseUpdateCodexLensEnvReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (request: CodexLensUpdateEnvRequest) => updateCodexLensEnv(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.env() });
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.dashboard() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updateEnv: mutation.mutateAsync,
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseSelectGpuReturn {
|
||||
selectGpu: (deviceId: string | number) => Promise<{ success: boolean; message?: string }>;
|
||||
resetGpu: () => Promise<{ success: boolean; message?: string }>;
|
||||
isSelecting: boolean;
|
||||
isResetting: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for selecting/resetting GPU for CodexLens
|
||||
*/
|
||||
export function useSelectGpu(): UseSelectGpuReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const selectMutation = useMutation({
|
||||
mutationFn: (deviceId: string | number) => selectCodexLensGpu(deviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.gpu() });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => resetCodexLensGpu(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.gpu() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
selectGpu: selectMutation.mutateAsync,
|
||||
resetGpu: resetMutation.mutateAsync,
|
||||
isSelecting: selectMutation.isPending,
|
||||
isResetting: resetMutation.isPending,
|
||||
error: selectMutation.error || resetMutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseUpdateIgnorePatternsReturn {
|
||||
updatePatterns: (request: CodexLensUpdateIgnorePatternsRequest) => Promise<CodexLensIgnorePatternsResponse>;
|
||||
isUpdating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating CodexLens ignore patterns
|
||||
*/
|
||||
export function useUpdateIgnorePatterns(): UseUpdateIgnorePatternsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateCodexLensIgnorePatterns,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: codexLensKeys.ignorePatterns() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
updatePatterns: mutation.mutateAsync,
|
||||
isUpdating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for all CodexLens mutations
|
||||
*/
|
||||
export function useCodexLensMutations() {
|
||||
const updateConfig = useUpdateCodexLensConfig();
|
||||
const bootstrap = useBootstrapCodexLens();
|
||||
const uninstall = useUninstallCodexLens();
|
||||
const download = useDownloadModel();
|
||||
const deleteModel = useDeleteModel();
|
||||
const updateEnv = useUpdateCodexLensEnv();
|
||||
const gpu = useSelectGpu();
|
||||
const updatePatterns = useUpdateIgnorePatterns();
|
||||
|
||||
return {
|
||||
updateConfig: updateConfig.updateConfig,
|
||||
isUpdatingConfig: updateConfig.isUpdating,
|
||||
bootstrap: bootstrap.bootstrap,
|
||||
isBootstrapping: bootstrap.isBootstrapping,
|
||||
uninstall: uninstall.uninstall,
|
||||
isUninstalling: uninstall.isUninstalling,
|
||||
downloadModel: download.downloadModel,
|
||||
downloadCustomModel: download.downloadCustomModel,
|
||||
isDownloading: download.isDownloading,
|
||||
deleteModel: deleteModel.deleteModel,
|
||||
deleteModelByPath: deleteModel.deleteModelByPath,
|
||||
isDeleting: deleteModel.isDeleting,
|
||||
updateEnv: updateEnv.updateEnv,
|
||||
isUpdatingEnv: updateEnv.isUpdating,
|
||||
selectGpu: gpu.selectGpu,
|
||||
resetGpu: gpu.resetGpu,
|
||||
isSelectingGpu: gpu.isSelecting || gpu.isResetting,
|
||||
updatePatterns: updatePatterns.updatePatterns,
|
||||
isUpdatingPatterns: updatePatterns.isUpdating,
|
||||
isMutating:
|
||||
updateConfig.isUpdating ||
|
||||
bootstrap.isBootstrapping ||
|
||||
uninstall.isUninstalling ||
|
||||
download.isDownloading ||
|
||||
deleteModel.isDeleting ||
|
||||
updateEnv.isUpdating ||
|
||||
gpu.isSelecting ||
|
||||
gpu.isResetting ||
|
||||
updatePatterns.isUpdating,
|
||||
};
|
||||
}
|
||||
@@ -52,13 +52,12 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: commandsKeys.list(filter),
|
||||
queryFn: () => fetchCommands(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
enabled: enabled, // Remove projectPath requirement
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
deactivateQueue,
|
||||
deleteQueue as deleteQueueApi,
|
||||
mergeQueues as mergeQueuesApi,
|
||||
splitQueue as splitQueueApi,
|
||||
fetchDiscoveries,
|
||||
fetchDiscoveryFindings,
|
||||
exportDiscoveryFindingsAsIssues,
|
||||
type Issue,
|
||||
type IssueQueue,
|
||||
type IssuesResponse,
|
||||
@@ -306,10 +308,12 @@ export interface UseQueueMutationsReturn {
|
||||
deactivateQueue: () => Promise<void>;
|
||||
deleteQueue: (queueId: string) => Promise<void>;
|
||||
mergeQueues: (sourceId: string, targetId: string) => Promise<void>;
|
||||
splitQueue: (sourceQueueId: string, itemIds: string[]) => Promise<void>;
|
||||
isActivating: boolean;
|
||||
isDeactivating: boolean;
|
||||
isDeleting: boolean;
|
||||
isMerging: boolean;
|
||||
isSplitting: boolean;
|
||||
isMutating: boolean;
|
||||
}
|
||||
|
||||
@@ -346,16 +350,26 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
},
|
||||
});
|
||||
|
||||
const splitMutation = useMutation({
|
||||
mutationFn: ({ sourceQueueId, itemIds }: { sourceQueueId: string; itemIds: string[] }) =>
|
||||
splitQueueApi(sourceQueueId, itemIds, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
activateQueue: activateMutation.mutateAsync,
|
||||
deactivateQueue: deactivateMutation.mutateAsync,
|
||||
deleteQueue: deleteMutation.mutateAsync,
|
||||
mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }),
|
||||
splitQueue: (sourceQueueId, itemIds) => splitMutation.mutateAsync({ sourceQueueId, itemIds }),
|
||||
isActivating: activateMutation.isPending,
|
||||
isDeactivating: deactivateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isMerging: mergeMutation.isPending,
|
||||
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending,
|
||||
isSplitting: splitMutation.isPending,
|
||||
isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending || splitMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -365,6 +379,8 @@ export interface FindingFilters {
|
||||
severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
type?: string;
|
||||
search?: string;
|
||||
exported?: boolean;
|
||||
hasIssue?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIssueDiscoveryReturn {
|
||||
@@ -380,6 +396,8 @@ export interface UseIssueDiscoveryReturn {
|
||||
selectSession: (sessionId: string) => void;
|
||||
refetchSessions: () => void;
|
||||
exportFindings: () => void;
|
||||
exportSelectedFindings: (findingIds: string[]) => Promise<{ success: boolean; message?: string; exported?: number }>;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn {
|
||||
@@ -388,6 +406,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<FindingFilters>({});
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: workspaceQueryKeys.discoveries(projectPath),
|
||||
@@ -426,6 +445,14 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
|
||||
f.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
// Filter by exported status
|
||||
if (filters.exported !== undefined) {
|
||||
findings = findings.filter(f => f.exported === filters.exported);
|
||||
}
|
||||
// Filter by hasIssue (has associated issue_id)
|
||||
if (filters.hasIssue !== undefined) {
|
||||
findings = findings.filter(f => !!f.issue_id === filters.hasIssue);
|
||||
}
|
||||
return findings;
|
||||
}, [findingsQuery.data, filters]);
|
||||
|
||||
@@ -449,6 +476,26 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportSelectedFindings = async (findingIds: string[]) => {
|
||||
if (!activeSessionId) return { success: false, message: 'No active session' };
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const result = await exportDiscoveryFindingsAsIssues(
|
||||
activeSessionId,
|
||||
{ findingIds },
|
||||
projectPath
|
||||
);
|
||||
// Invalidate queries to refresh findings with updated exported status
|
||||
await queryClient.invalidateQueries({ queryKey: ['discoveryFindings', activeSessionId, projectPath] });
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issues(projectPath) });
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { success: false, message: error instanceof Error ? error.message : 'Export failed' };
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
sessions: sessionsQuery.data ?? [],
|
||||
activeSession,
|
||||
@@ -464,5 +511,7 @@ export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIs
|
||||
sessionsQuery.refetch();
|
||||
},
|
||||
exportFindings,
|
||||
exportSelectedFindings,
|
||||
isExporting,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,14 +58,11 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Only enable query when projectPath is available
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: workspaceQueryKeys.skillsList(projectPath),
|
||||
queryFn: () => fetchSkills(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
enabled: enabled, // Remove projectPath requirement - API works without it
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
@@ -146,13 +146,16 @@ async function fetchApi<T>(
|
||||
status: response.status,
|
||||
};
|
||||
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body.message) error.message = body.message;
|
||||
if (body.code) error.code = body.code;
|
||||
} catch (parseError) {
|
||||
// Log parse errors instead of silently ignoring
|
||||
console.warn('[API] Failed to parse error response:', parseError);
|
||||
// Only try to parse JSON if the content type indicates JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body.message) error.message = body.message;
|
||||
if (body.code) error.code = body.code;
|
||||
} catch (parseError) {
|
||||
// Silently ignore JSON parse errors for non-JSON responses
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -599,12 +602,26 @@ export interface Issue {
|
||||
assignee?: string;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
item_id: string;
|
||||
issue_id: string;
|
||||
solution_id: string;
|
||||
task_id?: string;
|
||||
status: 'pending' | 'ready' | 'executing' | 'completed' | 'failed' | 'blocked';
|
||||
execution_order: number;
|
||||
execution_group: string;
|
||||
depends_on: string[];
|
||||
semantic_priority: number;
|
||||
files_touched?: string[];
|
||||
task_count?: number;
|
||||
}
|
||||
|
||||
export interface IssueQueue {
|
||||
tasks: string[];
|
||||
solutions: string[];
|
||||
conflicts: string[];
|
||||
execution_groups: string[];
|
||||
grouped_items: Record<string, string[]>;
|
||||
grouped_items: Record<string, QueueItem[]>;
|
||||
}
|
||||
|
||||
export interface IssuesResponse {
|
||||
@@ -683,6 +700,37 @@ export async function deleteIssue(issueId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull issues from GitHub
|
||||
*/
|
||||
export interface GitHubPullOptions {
|
||||
state?: 'open' | 'closed' | 'all';
|
||||
limit?: number;
|
||||
labels?: string;
|
||||
downloadImages?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubPullResponse {
|
||||
imported: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
images_downloaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function pullIssuesFromGitHub(options: GitHubPullOptions = {}): Promise<GitHubPullResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.state) params.set('state', options.state);
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.labels) params.set('labels', options.labels);
|
||||
if (options.downloadImages) params.set('downloadImages', 'true');
|
||||
|
||||
const url = `/api/issues/pull${params.toString() ? '?' + params.toString() : ''}`;
|
||||
return fetchApi<GitHubPullResponse>(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a queue
|
||||
*/
|
||||
@@ -720,6 +768,16 @@ export async function mergeQueues(sourceId: string, targetId: string, projectPat
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Split queue - split items from source queue into a new queue
|
||||
*/
|
||||
export async function splitQueue(sourceQueueId: string, itemIds: string[], projectPath: string): Promise<void> {
|
||||
return fetchApi<void>(`/api/queue/split?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceQueueId, itemIds }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Discovery API ==========
|
||||
|
||||
export interface DiscoverySession {
|
||||
@@ -743,14 +801,42 @@ export interface Finding {
|
||||
line?: number;
|
||||
code_snippet?: string;
|
||||
created_at: string;
|
||||
issue_id?: string; // Associated issue ID if exported
|
||||
exported?: boolean; // Whether this finding has been exported as an issue
|
||||
}
|
||||
|
||||
export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> {
|
||||
const url = projectPath
|
||||
? `/api/discoveries?path=${encodeURIComponent(projectPath)}`
|
||||
: '/api/discoveries';
|
||||
const data = await fetchApi<{ sessions?: DiscoverySession[] }>(url);
|
||||
return data.sessions ?? [];
|
||||
const data = await fetchApi<{ discoveries?: any[]; sessions?: DiscoverySession[] }>(url);
|
||||
|
||||
// Backend returns 'discoveries' with different schema, transform to frontend format
|
||||
const rawDiscoveries = data.discoveries ?? data.sessions ?? [];
|
||||
|
||||
// Map backend schema to frontend DiscoverySession interface
|
||||
return rawDiscoveries.map((d: any) => {
|
||||
// Map phase to status
|
||||
let status: 'running' | 'completed' | 'failed' = 'running';
|
||||
if (d.phase === 'complete' || d.phase === 'completed') {
|
||||
status = 'completed';
|
||||
} else if (d.phase === 'failed') {
|
||||
status = 'failed';
|
||||
}
|
||||
|
||||
// Extract progress percentage from nested progress object
|
||||
const progress = d.progress?.perspective_analysis?.percent_complete ?? 0;
|
||||
|
||||
return {
|
||||
id: d.discovery_id || d.id,
|
||||
name: d.target_pattern || d.discovery_id || d.name || 'Discovery',
|
||||
status,
|
||||
progress,
|
||||
findings_count: d.total_findings ?? d.findings_count ?? 0,
|
||||
created_at: d.created_at,
|
||||
completed_at: d.completed_at
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDiscoveryDetail(
|
||||
@@ -774,6 +860,27 @@ export async function fetchDiscoveryFindings(
|
||||
return data.findings ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export findings as issues
|
||||
* @param sessionId - Discovery session ID
|
||||
* @param findingIds - Array of finding IDs to export
|
||||
* @param exportAll - Export all findings if true
|
||||
* @param projectPath - Optional project path
|
||||
*/
|
||||
export async function exportDiscoveryFindingsAsIssues(
|
||||
sessionId: string,
|
||||
{ findingIds, exportAll }: { findingIds?: string[]; exportAll?: boolean },
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; message?: string; exported?: number }> {
|
||||
const url = projectPath
|
||||
? `/api/discoveries/${encodeURIComponent(sessionId)}/export?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/discoveries/${encodeURIComponent(sessionId)}/export`;
|
||||
return fetchApi<{ success: boolean; message?: string; exported?: number }>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ finding_ids: findingIds, export_all: exportAll }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Skills API ==========
|
||||
|
||||
export interface Skill {
|
||||
@@ -796,10 +903,30 @@ export interface SkillsResponse {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
||||
const url = projectPath ? `/api/skills?path=${encodeURIComponent(projectPath)}` : '/api/skills';
|
||||
const data = await fetchApi<{ skills?: Skill[] }>(url);
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
|
||||
const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])];
|
||||
return {
|
||||
skills: data.skills ?? allSkills,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === 403 || apiError.status === 404) {
|
||||
// Fall back to global skills list
|
||||
console.warn('[fetchSkills] 403/404 for project path, falling back to global skills');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global skills
|
||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills');
|
||||
const allSkills = [...(data.projectSkills ?? []), ...(data.userSkills ?? [])];
|
||||
return {
|
||||
skills: data.skills ?? [],
|
||||
skills: data.skills ?? allSkills,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -834,10 +961,30 @@ export interface CommandsResponse {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchCommands(projectPath?: string): Promise<CommandsResponse> {
|
||||
const url = projectPath ? `/api/commands?path=${encodeURIComponent(projectPath)}` : '/api/commands';
|
||||
const data = await fetchApi<{ commands?: Command[] }>(url);
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>(url);
|
||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||
return {
|
||||
commands: data.commands ?? allCommands,
|
||||
};
|
||||
} 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');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global commands
|
||||
const data = await fetchApi<{ commands?: Command[]; projectCommands?: Command[]; userCommands?: Command[] }>('/api/commands');
|
||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||
return {
|
||||
commands: data.commands ?? [],
|
||||
commands: data.commands ?? allCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -864,12 +1011,36 @@ export interface MemoryResponse {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchMemories(projectPath?: string): Promise<MemoryResponse> {
|
||||
const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory';
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/memory?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{
|
||||
memories?: CoreMemory[];
|
||||
totalSize?: number;
|
||||
claudeMdCount?: number;
|
||||
}>(url);
|
||||
return {
|
||||
memories: data.memories ?? [],
|
||||
totalSize: data.totalSize ?? 0,
|
||||
claudeMdCount: data.claudeMdCount ?? 0,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === 403 || apiError.status === 404) {
|
||||
// Fall back to global memories list
|
||||
console.warn('[fetchMemories] 403/404 for project path, falling back to global memories');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global memories
|
||||
const data = await fetchApi<{
|
||||
memories?: CoreMemory[];
|
||||
totalSize?: number;
|
||||
claudeMdCount?: number;
|
||||
}>(url);
|
||||
}>('/api/memory');
|
||||
return {
|
||||
memories: data.memories ?? [],
|
||||
totalSize: data.totalSize ?? 0,
|
||||
@@ -1027,6 +1198,65 @@ export interface SessionDetailContext {
|
||||
tech_stack?: string[];
|
||||
conventions?: string[];
|
||||
};
|
||||
// Extended context fields for context-package.json
|
||||
context?: {
|
||||
metadata?: {
|
||||
task_description?: string;
|
||||
session_id?: string;
|
||||
complexity?: string;
|
||||
keywords?: string[];
|
||||
};
|
||||
project_context?: {
|
||||
tech_stack?: {
|
||||
languages?: Array<{ name: string; file_count?: number }>;
|
||||
frameworks?: string[];
|
||||
libraries?: string[];
|
||||
};
|
||||
architecture_patterns?: string[];
|
||||
};
|
||||
assets?: {
|
||||
documentation?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||
source_code?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||
tests?: Array<{ path: string; relevance_score?: number; scope?: string; contains?: string[] }>;
|
||||
};
|
||||
dependencies?: {
|
||||
internal?: Array<{ from: string; type: string; to: string }>;
|
||||
external?: Array<{ package: string; version?: string; usage?: string }>;
|
||||
};
|
||||
test_context?: {
|
||||
frameworks?: {
|
||||
backend?: { name?: string; plugins?: string[] };
|
||||
frontend?: { name?: string };
|
||||
};
|
||||
existing_tests?: string[];
|
||||
coverage_config?: Record<string, unknown>;
|
||||
test_markers?: string[];
|
||||
};
|
||||
conflict_detection?: {
|
||||
risk_level?: 'low' | 'medium' | 'high' | 'critical';
|
||||
mitigation_strategy?: string;
|
||||
risk_factors?: {
|
||||
test_gaps?: string[];
|
||||
existing_implementations?: string[];
|
||||
};
|
||||
affected_modules?: string[];
|
||||
};
|
||||
};
|
||||
explorations?: {
|
||||
manifest: {
|
||||
task_description: string;
|
||||
complexity?: string;
|
||||
exploration_count: number;
|
||||
};
|
||||
data: Record<string, {
|
||||
project_structure?: string[];
|
||||
relevant_files?: string[];
|
||||
patterns?: string[];
|
||||
dependencies?: string[];
|
||||
integration_points?: string[];
|
||||
testing?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
@@ -1136,6 +1366,47 @@ export async function deleteAllHistory(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Task Status Update API ==========
|
||||
|
||||
/**
|
||||
* Bulk update task status for multiple tasks
|
||||
* @param sessionPath - Path to session directory
|
||||
* @param taskIds - Array of task IDs to update
|
||||
* @param newStatus - New status to set
|
||||
*/
|
||||
export async function bulkUpdateTaskStatus(
|
||||
sessionPath: string,
|
||||
taskIds: string[],
|
||||
newStatus: TaskStatus
|
||||
): Promise<{ success: boolean; updated: number; error?: string }> {
|
||||
return fetchApi('/api/bulk-update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionPath, taskIds, newStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update single task status
|
||||
* @param sessionPath - Path to session directory
|
||||
* @param taskId - Task ID to update
|
||||
* @param newStatus - New status to set
|
||||
*/
|
||||
export async function updateTaskStatus(
|
||||
sessionPath: string,
|
||||
taskId: string,
|
||||
newStatus: TaskStatus
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return fetchApi('/api/update-task-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionPath, taskId, newStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
// Task status type (matches TaskData.status)
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||
|
||||
/**
|
||||
* Fetch CLI execution detail (conversation records)
|
||||
*/
|
||||
@@ -1728,10 +1999,30 @@ export async function installHookTemplate(templateId: string): Promise<Hook> {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchRules(projectPath?: string): Promise<RulesResponse> {
|
||||
const url = projectPath ? `/api/rules?path=${encodeURIComponent(projectPath)}` : '/api/rules';
|
||||
const data = await fetchApi<{ rules?: Rule[] }>(url);
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/rules?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{ rules?: Rule[]; projectRules?: Rule[]; userRules?: Rule[] }>(url);
|
||||
const allRules = [...(data.projectRules ?? []), ...(data.userRules ?? [])];
|
||||
return {
|
||||
rules: data.rules ?? allRules,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === 403 || apiError.status === 404) {
|
||||
// Fall back to global rules list
|
||||
console.warn('[fetchRules] 403/404 for project path, falling back to global rules');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global rules
|
||||
const data = await fetchApi<{ rules?: Rule[]; projectRules?: Rule[]; userRules?: Rule[] }>('/api/rules');
|
||||
const allRules = [...(data.projectRules ?? []), ...(data.userRules ?? [])];
|
||||
return {
|
||||
rules: data.rules ?? [],
|
||||
rules: data.rules ?? allRules,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2091,3 +2382,428 @@ export async function fetchGraphImpact(request: GraphImpactRequest): Promise<Gra
|
||||
return fetchApi<GraphImpactResponse>(`/api/graph/impact?${params.toString()}`);
|
||||
}
|
||||
|
||||
// ========== CodexLens API ==========
|
||||
|
||||
/**
|
||||
* CodexLens venv status response
|
||||
*/
|
||||
export interface CodexLensVenvStatus {
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
pythonVersion?: string;
|
||||
venvPath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens status data
|
||||
*/
|
||||
export interface CodexLensStatusData {
|
||||
projects_count?: number;
|
||||
total_files?: number;
|
||||
total_chunks?: number;
|
||||
api_url?: string;
|
||||
api_ready?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens configuration
|
||||
*/
|
||||
export interface CodexLensConfig {
|
||||
index_dir: string;
|
||||
index_count: number;
|
||||
api_max_workers: number;
|
||||
api_batch_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic search status
|
||||
*/
|
||||
export interface CodexLensSemanticStatus {
|
||||
available: boolean;
|
||||
backend?: string;
|
||||
model?: string;
|
||||
hasEmbeddings?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard init response
|
||||
*/
|
||||
export interface CodexLensDashboardInitResponse {
|
||||
installed: boolean;
|
||||
status: CodexLensVenvStatus;
|
||||
config: CodexLensConfig;
|
||||
semantic: CodexLensSemanticStatus;
|
||||
statusData?: CodexLensStatusData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace index status
|
||||
*/
|
||||
export interface CodexLensWorkspaceStatus {
|
||||
success: boolean;
|
||||
hasIndex: boolean;
|
||||
path?: string;
|
||||
fts: {
|
||||
percent: number;
|
||||
indexedFiles: number;
|
||||
totalFiles: number;
|
||||
};
|
||||
vector: {
|
||||
percent: number;
|
||||
filesWithEmbeddings: number;
|
||||
totalFiles: number;
|
||||
totalChunks: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GPU device info
|
||||
*/
|
||||
export interface CodexLensGpuDevice {
|
||||
name: string;
|
||||
type: 'integrated' | 'discrete';
|
||||
index: number;
|
||||
device_id?: string;
|
||||
memory?: {
|
||||
total?: number;
|
||||
free?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GPU detect response
|
||||
*/
|
||||
export interface CodexLensGpuDetectResponse {
|
||||
success: boolean;
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
deviceCount?: number;
|
||||
devices?: CodexLensGpuDevice[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GPU list response
|
||||
*/
|
||||
export interface CodexLensGpuListResponse {
|
||||
success: boolean;
|
||||
devices: CodexLensGpuDevice[];
|
||||
selected_device_id?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model info
|
||||
*/
|
||||
export interface CodexLensModel {
|
||||
profile: string;
|
||||
name: string;
|
||||
type: 'embedding' | 'reranker';
|
||||
backend: string;
|
||||
size?: string;
|
||||
installed: boolean;
|
||||
cache_path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list response
|
||||
*/
|
||||
export interface CodexLensModelsResponse {
|
||||
success: boolean;
|
||||
models: CodexLensModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model info response
|
||||
*/
|
||||
export interface CodexLensModelInfoResponse {
|
||||
success: boolean;
|
||||
profile: string;
|
||||
info: {
|
||||
name: string;
|
||||
backend: string;
|
||||
type: string;
|
||||
size?: string;
|
||||
path?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download model response
|
||||
*/
|
||||
export interface CodexLensDownloadModelResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
profile?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete model response
|
||||
*/
|
||||
export interface CodexLensDeleteModelResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variables response
|
||||
*/
|
||||
export interface CodexLensEnvResponse {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
env: Record<string, string>;
|
||||
raw?: string;
|
||||
settings?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update environment request
|
||||
*/
|
||||
export interface CodexLensUpdateEnvRequest {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update environment response
|
||||
*/
|
||||
export interface CodexLensUpdateEnvResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
path?: string;
|
||||
settingsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore patterns response
|
||||
*/
|
||||
export interface CodexLensIgnorePatternsResponse {
|
||||
success: boolean;
|
||||
patterns: string[];
|
||||
extensionFilters: string[];
|
||||
defaults: {
|
||||
patterns: string[];
|
||||
extensionFilters: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ignore patterns request
|
||||
*/
|
||||
export interface CodexLensUpdateIgnorePatternsRequest {
|
||||
patterns?: string[];
|
||||
extensionFilters?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap install response
|
||||
*/
|
||||
export interface CodexLensBootstrapResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall response
|
||||
*/
|
||||
export interface CodexLensUninstallResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens dashboard initialization data
|
||||
*/
|
||||
export async function fetchCodexLensDashboardInit(): Promise<CodexLensDashboardInitResponse> {
|
||||
return fetchApi<CodexLensDashboardInitResponse>('/api/codexlens/dashboard-init');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens venv status
|
||||
*/
|
||||
export async function fetchCodexLensStatus(): Promise<CodexLensVenvStatus> {
|
||||
return fetchApi<CodexLensVenvStatus>('/api/codexlens/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens workspace index status
|
||||
*/
|
||||
export async function fetchCodexLensWorkspaceStatus(projectPath: string): Promise<CodexLensWorkspaceStatus> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('path', projectPath);
|
||||
return fetchApi<CodexLensWorkspaceStatus>(`/api/codexlens/workspace-status?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens configuration
|
||||
*/
|
||||
export async function fetchCodexLensConfig(): Promise<CodexLensConfig> {
|
||||
return fetchApi<CodexLensConfig>('/api/codexlens/config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CodexLens configuration
|
||||
*/
|
||||
export async function updateCodexLensConfig(config: {
|
||||
index_dir: string;
|
||||
api_max_workers?: number;
|
||||
api_batch_size?: number;
|
||||
}): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return fetchApi('/api/codexlens/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap/install CodexLens
|
||||
*/
|
||||
export async function bootstrapCodexLens(): Promise<CodexLensBootstrapResponse> {
|
||||
return fetchApi<CodexLensBootstrapResponse>('/api/codexlens/bootstrap', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall CodexLens
|
||||
*/
|
||||
export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse> {
|
||||
return fetchApi<CodexLensUninstallResponse>('/api/codexlens/uninstall', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens models list
|
||||
*/
|
||||
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
|
||||
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens model info by profile
|
||||
*/
|
||||
export async function fetchCodexLensModelInfo(profile: string): Promise<CodexLensModelInfoResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('profile', profile);
|
||||
return fetchApi<CodexLensModelInfoResponse>(`/api/codexlens/models/info?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download CodexLens model by profile
|
||||
*/
|
||||
export async function downloadCodexLensModel(profile: string): Promise<CodexLensDownloadModelResponse> {
|
||||
return fetchApi<CodexLensDownloadModelResponse>('/api/codexlens/models/download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download custom CodexLens model from HuggingFace
|
||||
*/
|
||||
export async function downloadCodexLensCustomModel(modelName: string, modelType: string = 'embedding'): Promise<CodexLensDownloadModelResponse> {
|
||||
return fetchApi<CodexLensDownloadModelResponse>('/api/codexlens/models/download-custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model_name: modelName, model_type: modelType }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete CodexLens model by profile
|
||||
*/
|
||||
export async function deleteCodexLensModel(profile: string): Promise<CodexLensDeleteModelResponse> {
|
||||
return fetchApi<CodexLensDeleteModelResponse>('/api/codexlens/models/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete CodexLens model by cache path
|
||||
*/
|
||||
export async function deleteCodexLensModelByPath(cachePath: string): Promise<CodexLensDeleteModelResponse> {
|
||||
return fetchApi<CodexLensDeleteModelResponse>('/api/codexlens/models/delete-path', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cache_path: cachePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens environment variables
|
||||
*/
|
||||
export async function fetchCodexLensEnv(): Promise<CodexLensEnvResponse> {
|
||||
return fetchApi<CodexLensEnvResponse>('/api/codexlens/env');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CodexLens environment variables
|
||||
*/
|
||||
export async function updateCodexLensEnv(request: CodexLensUpdateEnvRequest): Promise<CodexLensUpdateEnvResponse> {
|
||||
return fetchApi<CodexLensUpdateEnvResponse>('/api/codexlens/env', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GPU support for CodexLens
|
||||
*/
|
||||
export async function fetchCodexLensGpuDetect(): Promise<CodexLensGpuDetectResponse> {
|
||||
return fetchApi<CodexLensGpuDetectResponse>('/api/codexlens/gpu/detect');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available GPU devices
|
||||
*/
|
||||
export async function fetchCodexLensGpuList(): Promise<CodexLensGpuListResponse> {
|
||||
return fetchApi<CodexLensGpuListResponse>('/api/codexlens/gpu/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Select GPU device for CodexLens
|
||||
*/
|
||||
export async function selectCodexLensGpu(deviceId: string | number): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return fetchApi('/api/codexlens/gpu/select', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset GPU selection to auto-detection
|
||||
*/
|
||||
export async function resetCodexLensGpu(): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return fetchApi('/api/codexlens/gpu/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens ignore patterns
|
||||
*/
|
||||
export async function fetchCodexLensIgnorePatterns(): Promise<CodexLensIgnorePatternsResponse> {
|
||||
return fetchApi<CodexLensIgnorePatternsResponse>('/api/codexlens/ignore-patterns');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CodexLens ignore patterns
|
||||
*/
|
||||
export async function updateCodexLensIgnorePatterns(request: CodexLensUpdateIgnorePatternsRequest): Promise<CodexLensIgnorePatternsResponse> {
|
||||
return fetchApi<CodexLensIgnorePatternsResponse>('/api/codexlens/ignore-patterns', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
"automation": "Automation"
|
||||
},
|
||||
"templates": {
|
||||
"ccw-status-tracker": {
|
||||
"name": "CCW Status Tracker",
|
||||
"description": "Parse CCW status.json and display current/next command"
|
||||
},
|
||||
"ccw-notify": {
|
||||
"name": "CCW Dashboard Notify",
|
||||
"description": "Send notifications to CCW dashboard when files are written"
|
||||
|
||||
178
ccw/frontend/src/locales/en/codexlens.json
Normal file
178
ccw/frontend/src/locales/en/codexlens.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"title": "CodexLens",
|
||||
"description": "Semantic code search engine",
|
||||
"bootstrap": "Bootstrap",
|
||||
"bootstrapping": "Bootstrapping...",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling...",
|
||||
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.",
|
||||
"confirmUninstallTitle": "Confirm Uninstall",
|
||||
"notInstalled": "CodexLens is not installed",
|
||||
"comingSoon": "Coming Soon",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"settings": "Settings",
|
||||
"models": "Models",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"overview": {
|
||||
"status": {
|
||||
"installation": "Installation Status",
|
||||
"ready": "Ready",
|
||||
"notReady": "Not Ready",
|
||||
"version": "Version",
|
||||
"indexPath": "Index Path",
|
||||
"indexCount": "Index Count"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "CodexLens Not Installed",
|
||||
"message": "Please install CodexLens to use semantic code search features."
|
||||
},
|
||||
"actions": {
|
||||
"title": "Quick Actions",
|
||||
"ftsFull": "FTS Full",
|
||||
"ftsFullDesc": "Rebuild full-text index",
|
||||
"ftsIncremental": "FTS Incremental",
|
||||
"ftsIncrementalDesc": "Incremental update full-text index",
|
||||
"vectorFull": "Vector Full",
|
||||
"vectorFullDesc": "Rebuild vector index",
|
||||
"vectorIncremental": "Vector Incremental",
|
||||
"vectorIncrementalDesc": "Incremental update vector index"
|
||||
},
|
||||
"venv": {
|
||||
"title": "Python Virtual Environment Details",
|
||||
"pythonVersion": "Python Version",
|
||||
"venvPath": "Virtual Environment Path",
|
||||
"lastCheck": "Last Check Time"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"currentCount": "Current Index Count",
|
||||
"currentWorkers": "Current Workers",
|
||||
"currentBatchSize": "Current Batch Size",
|
||||
"configTitle": "Basic Configuration",
|
||||
"indexDir": {
|
||||
"label": "Index Directory",
|
||||
"placeholder": "~/.codexlens/indexes",
|
||||
"hint": "Directory path for storing code indexes"
|
||||
},
|
||||
"maxWorkers": {
|
||||
"label": "Max Workers",
|
||||
"hint": "API concurrent processing threads (1-32)"
|
||||
},
|
||||
"batchSize": {
|
||||
"label": "Batch Size",
|
||||
"hint": "Number of files processed per batch (1-64)"
|
||||
},
|
||||
"validation": {
|
||||
"indexDirRequired": "Index directory is required",
|
||||
"maxWorkersRange": "Workers must be between 1 and 32",
|
||||
"batchSizeRange": "Batch size must be between 1 and 64"
|
||||
},
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"reset": "Reset",
|
||||
"saveSuccess": "Configuration saved",
|
||||
"saveFailed": "Save failed",
|
||||
"configUpdated": "Configuration updated successfully",
|
||||
"saveError": "Error saving configuration",
|
||||
"unknownError": "An unknown error occurred"
|
||||
},
|
||||
"gpu": {
|
||||
"title": "GPU Settings",
|
||||
"status": "GPU Status",
|
||||
"enabled": "Enabled",
|
||||
"available": "Available",
|
||||
"unavailable": "Unavailable",
|
||||
"supported": "Your system supports GPU acceleration",
|
||||
"notSupported": "Your system does not support GPU acceleration",
|
||||
"detect": "Detect",
|
||||
"detectSuccess": "GPU detection completed",
|
||||
"detectFailed": "GPU detection failed",
|
||||
"detectComplete": "Detected {count} GPU devices",
|
||||
"detectError": "Error detecting GPU",
|
||||
"select": "Select",
|
||||
"selected": "Selected",
|
||||
"active": "Current",
|
||||
"selectSuccess": "GPU selected",
|
||||
"selectFailed": "GPU selection failed",
|
||||
"gpuSelected": "GPU device enabled",
|
||||
"selectError": "Error selecting GPU",
|
||||
"reset": "Reset",
|
||||
"resetSuccess": "GPU reset",
|
||||
"resetFailed": "GPU reset failed",
|
||||
"gpuReset": "GPU disabled, will use CPU",
|
||||
"resetError": "Error resetting GPU",
|
||||
"unknownError": "An unknown error occurred",
|
||||
"noDevices": "No GPU devices detected",
|
||||
"notAvailable": "GPU functionality not available",
|
||||
"unknownDevice": "Unknown device",
|
||||
"type": "Type",
|
||||
"driver": "Driver Version",
|
||||
"memory": "Memory"
|
||||
},
|
||||
"advanced": {
|
||||
"warningTitle": "Sensitive Operations Warning",
|
||||
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
|
||||
"currentVars": "Current Environment Variables",
|
||||
"settingsVars": "Settings Variables",
|
||||
"customVars": "Custom Variables",
|
||||
"envEditor": "Environment Variable Editor",
|
||||
"envFile": "File",
|
||||
"envContent": "Environment Variable Content",
|
||||
"envPlaceholder": "# Comment lines start with #\nKEY=value\nANOTHER_KEY=\"another value\"",
|
||||
"envHint": "One variable per line, format: KEY=value. Comment lines start with #",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"reset": "Reset",
|
||||
"saveSuccess": "Environment variables saved",
|
||||
"saveFailed": "Save failed",
|
||||
"envUpdated": "Environment variables updated, restart service to take effect",
|
||||
"saveError": "Error saving environment variables",
|
||||
"unknownError": "An unknown error occurred",
|
||||
"validation": {
|
||||
"invalidKeys": "Invalid variable names: {keys}"
|
||||
},
|
||||
"helpTitle": "Format Help",
|
||||
"helpComment": "Comment lines start with #",
|
||||
"helpFormat": "Variable format: KEY=value",
|
||||
"helpQuotes": "Values with spaces should use quotes",
|
||||
"helpRestart": "Restart service after changes to take effect"
|
||||
},
|
||||
"models": {
|
||||
"title": "Model Management",
|
||||
"searchPlaceholder": "Search models...",
|
||||
"downloading": "Downloading...",
|
||||
"status": {
|
||||
"downloaded": "Downloaded",
|
||||
"available": "Available"
|
||||
},
|
||||
"types": {
|
||||
"embedding": "Embedding Models",
|
||||
"reranker": "Reranker Models"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filter",
|
||||
"all": "All"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"custom": {
|
||||
"title": "Custom Model",
|
||||
"placeholder": "HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)",
|
||||
"description": "Download custom models from HuggingFace. Ensure the model name is correct."
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete model {modelName}?",
|
||||
"notInstalled": {
|
||||
"title": "CodexLens Not Installed",
|
||||
"description": "Please install CodexLens to use model management features."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No models found",
|
||||
"description": "Try adjusting your search or filter criteria"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import skills from './skills.json';
|
||||
import cliManager from './cli-manager.json';
|
||||
import cliMonitor from './cli-monitor.json';
|
||||
import mcpManager from './mcp-manager.json';
|
||||
import codexlens from './codexlens.json';
|
||||
import theme from './theme.json';
|
||||
import executionMonitor from './execution-monitor.json';
|
||||
import cliHooks from './cli-hooks.json';
|
||||
@@ -77,9 +78,10 @@ export default {
|
||||
...flattenMessages(reviewSession, 'reviewSession'),
|
||||
...flattenMessages(sessionDetail, 'sessionDetail'),
|
||||
...flattenMessages(skills, 'skills'),
|
||||
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
|
||||
...flattenMessages(cliManager, 'cli-manager'),
|
||||
...flattenMessages(cliMonitor, 'cliMonitor'),
|
||||
...flattenMessages(mcpManager, 'mcp'),
|
||||
...flattenMessages(codexlens, 'codexlens'),
|
||||
...flattenMessages(theme, 'theme'),
|
||||
...flattenMessages(cliHooks, 'cliHooks'),
|
||||
...flattenMessages(executionMonitor, 'executionMonitor'),
|
||||
|
||||
@@ -97,6 +97,23 @@
|
||||
"noSessions": "No sessions found",
|
||||
"noSessionsDescription": "Start a new discovery session to begin",
|
||||
"findingsDetail": "Findings Detail",
|
||||
"selectSession": "Select a session to view findings",
|
||||
"sessionId": "Session ID",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"createdAt": "Created At",
|
||||
"completedAt": "Completed At",
|
||||
"progress": "Progress",
|
||||
"findingsCount": "Findings Count",
|
||||
"export": "Export JSON",
|
||||
"exportSelected": "Export Selected ({count})",
|
||||
"exporting": "Exporting...",
|
||||
"exportAsIssues": "Export as Issues",
|
||||
"severityBreakdown": "Severity Breakdown",
|
||||
"typeBreakdown": "Type Breakdown",
|
||||
"tabFindings": "Findings",
|
||||
"tabProgress": "Progress",
|
||||
"tabInfo": "Session Info",
|
||||
"stats": {
|
||||
"totalSessions": "Total Sessions",
|
||||
"completed": "Completed",
|
||||
@@ -129,8 +146,31 @@
|
||||
"type": {
|
||||
"all": "All Types"
|
||||
},
|
||||
"exportedStatus": {
|
||||
"all": "All Export Status",
|
||||
"exported": "Exported",
|
||||
"notExported": "Not Exported"
|
||||
},
|
||||
"issueStatus": {
|
||||
"all": "All Issue Status",
|
||||
"hasIssue": "Has Issue",
|
||||
"noIssue": "No Issue"
|
||||
},
|
||||
"noFindings": "No findings found",
|
||||
"export": "Export"
|
||||
"noFindingsDescription": "No matching findings found",
|
||||
"searchPlaceholder": "Search findings...",
|
||||
"filterBySeverity": "Filter by severity",
|
||||
"filterByType": "Filter by type",
|
||||
"filterByExported": "Filter by export status",
|
||||
"filterByIssue": "Filter by issue link",
|
||||
"allSeverities": "All severities",
|
||||
"allTypes": "All types",
|
||||
"showingCount": "Showing {count} findings",
|
||||
"exported": "Exported",
|
||||
"hasIssue": "Linked",
|
||||
"export": "Export",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All"
|
||||
},
|
||||
"tabs": {
|
||||
"findings": "Findings",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"prompts": "Prompt History",
|
||||
"settings": "Settings",
|
||||
"mcp": "MCP Servers",
|
||||
"codexlens": "CodexLens",
|
||||
"endpoints": "CLI Endpoints",
|
||||
"installations": "Installations",
|
||||
"help": "Help",
|
||||
|
||||
@@ -6,13 +6,25 @@
|
||||
"tabs": {
|
||||
"tasks": "Tasks",
|
||||
"context": "Context",
|
||||
"summary": "Summary"
|
||||
"summary": "Summary",
|
||||
"implPlan": "IMPL Plan",
|
||||
"conflict": "Conflict",
|
||||
"review": "Review"
|
||||
},
|
||||
"tasks": {
|
||||
"completed": "completed",
|
||||
"inProgress": "in progress",
|
||||
"pending": "pending",
|
||||
"blocked": "blocked",
|
||||
"quickActions": {
|
||||
"markAllPending": "All Pending",
|
||||
"markAllInProgress": "All In Progress",
|
||||
"markAllCompleted": "All Completed"
|
||||
},
|
||||
"statusUpdate": {
|
||||
"success": "Task status updated successfully",
|
||||
"error": "Failed to update task status"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"inProgress": "In Progress",
|
||||
@@ -36,15 +48,114 @@
|
||||
"empty": {
|
||||
"title": "No Context Available",
|
||||
"message": "This session has no context information."
|
||||
},
|
||||
"explorations": {
|
||||
"title": "Explorations",
|
||||
"angles": "angles",
|
||||
"projectStructure": "Project Structure",
|
||||
"relevantFiles": "Relevant Files",
|
||||
"patterns": "Patterns",
|
||||
"dependencies": "Dependencies",
|
||||
"integrationPoints": "Integration Points",
|
||||
"testing": "Testing"
|
||||
},
|
||||
"categories": {
|
||||
"documentation": "Documentation",
|
||||
"sourceCode": "Source Code",
|
||||
"tests": "Tests"
|
||||
},
|
||||
"assets": {
|
||||
"title": "Assets",
|
||||
"noData": "No assets found",
|
||||
"scope": "Scope",
|
||||
"contains": "Contains"
|
||||
},
|
||||
"dependencies": {
|
||||
"title": "Dependencies",
|
||||
"internal": "Internal",
|
||||
"external": "External",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"type": "Type"
|
||||
},
|
||||
"testContext": {
|
||||
"title": "Test Context",
|
||||
"tests": "tests",
|
||||
"existingTests": "existing tests",
|
||||
"markers": "markers",
|
||||
"coverage": "Coverage Configuration",
|
||||
"backend": "Backend",
|
||||
"frontend": "Frontend",
|
||||
"framework": "Framework"
|
||||
},
|
||||
"conflictDetection": {
|
||||
"title": "Conflict Detection",
|
||||
"riskLevel": {
|
||||
"low": "Low Risk",
|
||||
"medium": "Medium Risk",
|
||||
"high": "High Risk",
|
||||
"critical": "Critical Risk"
|
||||
},
|
||||
"mitigation": "Mitigation Strategy",
|
||||
"riskFactors": "Risk Factors",
|
||||
"testGaps": "Test Gaps",
|
||||
"existingImplementations": "Existing Implementations",
|
||||
"affectedModules": "Affected Modules"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"default": "Summary",
|
||||
"title": "Session Summary",
|
||||
"lines": "lines",
|
||||
"empty": {
|
||||
"title": "No Summary Available",
|
||||
"message": "This session has no summary yet."
|
||||
}
|
||||
},
|
||||
"implPlan": {
|
||||
"title": "Implementation Plan",
|
||||
"empty": {
|
||||
"title": "No IMPL Plan Available",
|
||||
"message": "This session has no implementation plan yet."
|
||||
},
|
||||
"viewFull": "View Full Plan ({count} lines)"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "Conflict Resolution",
|
||||
"comingSoon": "Conflict Resolution (Coming Soon)",
|
||||
"comingSoonMessage": "This tab will display conflict resolution decisions and user choices.",
|
||||
"empty": {
|
||||
"title": "No Conflict Resolution Data",
|
||||
"message": "This session has no conflict resolution information."
|
||||
},
|
||||
"resolvedAt": "Resolved",
|
||||
"userDecisions": "User Decisions",
|
||||
"description": "Description",
|
||||
"implications": "Implications",
|
||||
"resolvedConflicts": "Resolved Conflicts",
|
||||
"strategy": "Strategy"
|
||||
},
|
||||
"review": {
|
||||
"title": "Code Review",
|
||||
"comingSoon": "Code Review (Coming Soon)",
|
||||
"comingSoonMessage": "This tab will display review findings and recommendations.",
|
||||
"empty": {
|
||||
"title": "No Review Data",
|
||||
"message": "This session has no review information."
|
||||
},
|
||||
"noFindings": {
|
||||
"title": "No Findings Found",
|
||||
"message": "No findings match the current severity filter."
|
||||
},
|
||||
"filterBySeverity": "Filter by Severity",
|
||||
"severity": {
|
||||
"all": "All Severities",
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
"automation": "自动化"
|
||||
},
|
||||
"templates": {
|
||||
"ccw-status-tracker": {
|
||||
"name": "CCW 状态追踪器",
|
||||
"description": "解析 CCW status.json 并显示当前/下一个命令"
|
||||
},
|
||||
"ccw-notify": {
|
||||
"name": "CCW 面板通知",
|
||||
"description": "当文件被写入时向 CCW 面板发送通知"
|
||||
|
||||
178
ccw/frontend/src/locales/zh/codexlens.json
Normal file
178
ccw/frontend/src/locales/zh/codexlens.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"title": "CodexLens",
|
||||
"description": "语义代码搜索引擎",
|
||||
"bootstrap": "引导安装",
|
||||
"bootstrapping": "安装中...",
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中...",
|
||||
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。",
|
||||
"confirmUninstallTitle": "确认卸载",
|
||||
"notInstalled": "CodexLens 尚未安装",
|
||||
"comingSoon": "即将推出",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"settings": "设置",
|
||||
"models": "模型",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"overview": {
|
||||
"status": {
|
||||
"installation": "安装状态",
|
||||
"ready": "就绪",
|
||||
"notReady": "未就绪",
|
||||
"version": "版本",
|
||||
"indexPath": "索引路径",
|
||||
"indexCount": "索引数量"
|
||||
},
|
||||
"notInstalled": {
|
||||
"title": "CodexLens 未安装",
|
||||
"message": "请先安装 CodexLens 以使用语义代码搜索功能。"
|
||||
},
|
||||
"actions": {
|
||||
"title": "快速操作",
|
||||
"ftsFull": "FTS 全量",
|
||||
"ftsFullDesc": "重建全文索引",
|
||||
"ftsIncremental": "FTS 增量",
|
||||
"ftsIncrementalDesc": "增量更新全文索引",
|
||||
"vectorFull": "向量全量",
|
||||
"vectorFullDesc": "重建向量索引",
|
||||
"vectorIncremental": "向量增量",
|
||||
"vectorIncrementalDesc": "增量更新向量索引"
|
||||
},
|
||||
"venv": {
|
||||
"title": "Python 虚拟环境详情",
|
||||
"pythonVersion": "Python 版本",
|
||||
"venvPath": "虚拟环境路径",
|
||||
"lastCheck": "最后检查时间"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"currentCount": "当前索引数量",
|
||||
"currentWorkers": "当前工作线程",
|
||||
"currentBatchSize": "当前批次大小",
|
||||
"configTitle": "基本配置",
|
||||
"indexDir": {
|
||||
"label": "索引目录",
|
||||
"placeholder": "~/.codexlens/indexes",
|
||||
"hint": "存储代码索引的目录路径"
|
||||
},
|
||||
"maxWorkers": {
|
||||
"label": "最大工作线程",
|
||||
"hint": "API 并发处理线程数 (1-32)"
|
||||
},
|
||||
"batchSize": {
|
||||
"label": "批次大小",
|
||||
"hint": "每次批量处理的文件数量 (1-64)"
|
||||
},
|
||||
"validation": {
|
||||
"indexDirRequired": "索引目录不能为空",
|
||||
"maxWorkersRange": "工作线程数必须在 1-32 之间",
|
||||
"batchSizeRange": "批次大小必须在 1-64 之间"
|
||||
},
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"reset": "重置",
|
||||
"saveSuccess": "配置已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"configUpdated": "配置更新成功",
|
||||
"saveError": "保存配置时出错",
|
||||
"unknownError": "发生未知错误"
|
||||
},
|
||||
"gpu": {
|
||||
"title": "GPU 设置",
|
||||
"status": "GPU 状态",
|
||||
"enabled": "已启用",
|
||||
"available": "可用",
|
||||
"unavailable": "不可用",
|
||||
"supported": "您的系统支持 GPU 加速",
|
||||
"notSupported": "您的系统不支持 GPU 加速",
|
||||
"detect": "检测",
|
||||
"detectSuccess": "GPU 检测完成",
|
||||
"detectFailed": "GPU 检测失败",
|
||||
"detectComplete": "检测到 {count} 个 GPU 设备",
|
||||
"detectError": "检测 GPU 时出错",
|
||||
"select": "选择",
|
||||
"selected": "已选择",
|
||||
"active": "当前",
|
||||
"selectSuccess": "GPU 已选择",
|
||||
"selectFailed": "GPU 选择失败",
|
||||
"gpuSelected": "GPU 设备已启用",
|
||||
"selectError": "选择 GPU 时出错",
|
||||
"reset": "重置",
|
||||
"resetSuccess": "GPU 已重置",
|
||||
"resetFailed": "GPU 重置失败",
|
||||
"gpuReset": "GPU 已禁用,将使用 CPU",
|
||||
"resetError": "重置 GPU 时出错",
|
||||
"unknownError": "发生未知错误",
|
||||
"noDevices": "未检测到 GPU 设备",
|
||||
"notAvailable": "GPU 功能不可用",
|
||||
"unknownDevice": "未知设备",
|
||||
"type": "类型",
|
||||
"driver": "驱动版本",
|
||||
"memory": "显存"
|
||||
},
|
||||
"advanced": {
|
||||
"warningTitle": "敏感操作警告",
|
||||
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
|
||||
"currentVars": "当前环境变量",
|
||||
"settingsVars": "设置变量",
|
||||
"customVars": "自定义变量",
|
||||
"envEditor": "环境变量编辑器",
|
||||
"envFile": "文件",
|
||||
"envContent": "环境变量内容",
|
||||
"envPlaceholder": "# 注释行以 # 开头\nKEY=value\nANOTHER_KEY=\"another value\"",
|
||||
"envHint": "每行一个变量,格式:KEY=value。注释行以 # 开头",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"reset": "重置",
|
||||
"saveSuccess": "环境变量已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"envUpdated": "环境变量更新成功,重启服务后生效",
|
||||
"saveError": "保存环境变量时出错",
|
||||
"unknownError": "发生未知错误",
|
||||
"validation": {
|
||||
"invalidKeys": "无效的变量名: {keys}"
|
||||
},
|
||||
"helpTitle": "格式说明",
|
||||
"helpComment": "注释行以 # 开头",
|
||||
"helpFormat": "变量格式:KEY=value",
|
||||
"helpQuotes": "包含空格的值建议使用引号",
|
||||
"helpRestart": "修改后需要重启服务才能生效"
|
||||
},
|
||||
"models": {
|
||||
"title": "模型管理",
|
||||
"searchPlaceholder": "搜索模型...",
|
||||
"downloading": "下载中...",
|
||||
"status": {
|
||||
"downloaded": "已下载",
|
||||
"available": "可用"
|
||||
},
|
||||
"types": {
|
||||
"embedding": "嵌入模型",
|
||||
"reranker": "重排序模型"
|
||||
},
|
||||
"filters": {
|
||||
"label": "筛选",
|
||||
"all": "全部"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"custom": {
|
||||
"title": "自定义模型",
|
||||
"placeholder": "HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)",
|
||||
"description": "从 HuggingFace 下载自定义模型。请确保模型名称正确。"
|
||||
},
|
||||
"deleteConfirm": "确定要删除模型 {modelName} 吗?",
|
||||
"notInstalled": {
|
||||
"title": "CodexLens 未安装",
|
||||
"description": "请先安装 CodexLens 以使用模型管理功能。"
|
||||
},
|
||||
"empty": {
|
||||
"title": "没有找到模型",
|
||||
"description": "尝试调整搜索或筛选条件"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"resetDesc": "将所有用户偏好重置为默认值。此操作无法撤销。",
|
||||
"saving": "Saving...",
|
||||
"saving": "保存中...",
|
||||
"deleting": "删除中...",
|
||||
"merging": "合并中...",
|
||||
"splitting": "拆分中...",
|
||||
"resetConfirm": "确定要将所有设置重置为默认值吗?",
|
||||
"resetToDefaults": "重置为默认值",
|
||||
"enable": "启用",
|
||||
@@ -51,7 +54,8 @@
|
||||
"clearAll": "清除全部",
|
||||
"select": "选择",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选"
|
||||
"deselectAll": "取消全选",
|
||||
"openMenu": "打开菜单"
|
||||
},
|
||||
"status": {
|
||||
"active": "活跃",
|
||||
|
||||
@@ -23,6 +23,7 @@ import skills from './skills.json';
|
||||
import cliManager from './cli-manager.json';
|
||||
import cliMonitor from './cli-monitor.json';
|
||||
import mcpManager from './mcp-manager.json';
|
||||
import codexlens from './codexlens.json';
|
||||
import theme from './theme.json';
|
||||
import executionMonitor from './execution-monitor.json';
|
||||
import cliHooks from './cli-hooks.json';
|
||||
@@ -77,9 +78,10 @@ export default {
|
||||
...flattenMessages(reviewSession, 'reviewSession'),
|
||||
...flattenMessages(sessionDetail, 'sessionDetail'),
|
||||
...flattenMessages(skills, 'skills'),
|
||||
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
|
||||
...flattenMessages(cliManager, 'cli-manager'),
|
||||
...flattenMessages(cliMonitor, 'cliMonitor'),
|
||||
...flattenMessages(mcpManager, 'mcp'),
|
||||
...flattenMessages(codexlens, 'codexlens'),
|
||||
...flattenMessages(theme, 'theme'),
|
||||
...flattenMessages(cliHooks, 'cliHooks'),
|
||||
...flattenMessages(executionMonitor, 'executionMonitor'),
|
||||
|
||||
@@ -61,29 +61,130 @@
|
||||
"updatedAt": "更新时间",
|
||||
"solutions": "{count, plural, one {解决方案} other {解决方案}}"
|
||||
},
|
||||
"detail": {
|
||||
"title": "问题详情",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"solutions": "解决方案",
|
||||
"history": "历史",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
"title": "标题",
|
||||
"status": "状态",
|
||||
"priority": "优先级",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"context": "上下文",
|
||||
"labels": "标签",
|
||||
"assignee": "受理人"
|
||||
},
|
||||
"solutions": {
|
||||
"title": "解决方案",
|
||||
"empty": "暂无解决方案",
|
||||
"addSolution": "添加解决方案",
|
||||
"boundSolution": "已绑定解决方案"
|
||||
},
|
||||
"history": {
|
||||
"title": "历史记录",
|
||||
"empty": "暂无历史记录"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"title": "队列",
|
||||
"pageTitle": "问题队列",
|
||||
"description": "管理问题执行队列和执行组",
|
||||
"status": {
|
||||
"pending": "待处理",
|
||||
"ready": "就绪",
|
||||
"executing": "执行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"blocked": "已阻塞",
|
||||
"active": "活动",
|
||||
"inactive": "非活动"
|
||||
},
|
||||
"stats": {
|
||||
"totalItems": "总项目",
|
||||
"groups": "执行组",
|
||||
"tasks": "任务",
|
||||
"solutions": "解决方案"
|
||||
"solutions": "解决方案",
|
||||
"items": "项目",
|
||||
"executionGroups": "执行组"
|
||||
},
|
||||
"actions": {
|
||||
"activate": "激活",
|
||||
"deactivate": "停用",
|
||||
"delete": "删除",
|
||||
"merge": "合并",
|
||||
"split": "拆分",
|
||||
"confirmDelete": "确定要删除此队列吗?"
|
||||
},
|
||||
"executionGroup": "执行组",
|
||||
"executionGroups": "执行组",
|
||||
"parallelGroup": "并行组",
|
||||
"sequentialGroup": "顺序组",
|
||||
"items": "项目",
|
||||
"itemCount": "{count} 项",
|
||||
"groups": "组",
|
||||
"parallel": "并行",
|
||||
"sequential": "顺序",
|
||||
"emptyState": "无队列数据",
|
||||
"empty": "无数据",
|
||||
"conflicts": "队列中检测到冲突",
|
||||
"noQueueData": "无队列数据"
|
||||
"noQueueData": "无队列数据",
|
||||
"emptyState": {
|
||||
"title": "暂无队列",
|
||||
"description": "当前没有可用的执行队列"
|
||||
},
|
||||
"error": {
|
||||
"title": "加载失败",
|
||||
"message": "无法加载队列数据,请稍后重试"
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "队列冲突",
|
||||
"description": "个冲突"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除队列",
|
||||
"description": "确定要删除此队列吗?此操作无法撤销。"
|
||||
},
|
||||
"mergeDialog": {
|
||||
"title": "合并队列",
|
||||
"targetQueueLabel": "目标队列ID",
|
||||
"targetQueuePlaceholder": "输入要合并到的队列ID"
|
||||
},
|
||||
"splitDialog": {
|
||||
"title": "拆分队列",
|
||||
"selected": "已选择 {count}/{total} 项",
|
||||
"selectAll": "全选",
|
||||
"clearAll": "清空",
|
||||
"noSelection": "请选择要拆分的项目",
|
||||
"cannotSplitAll": "不能拆分所有项目,源队列至少需保留一项"
|
||||
}
|
||||
},
|
||||
"solution": {
|
||||
"issue": "问题",
|
||||
"solution": "解决方案",
|
||||
"shortIssue": "问题",
|
||||
"shortSolution": "方案",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"tasks": "任务",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
"executionInfo": "执行信息",
|
||||
"executionOrder": "执行顺序",
|
||||
"semanticPriority": "语义优先级",
|
||||
"group": "执行组",
|
||||
"taskCount": "任务数量",
|
||||
"dependencies": "依赖项",
|
||||
"filesTouched": "涉及文件"
|
||||
},
|
||||
"tasks": {
|
||||
"comingSoon": "任务列表即将推出"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"title": "发现",
|
||||
@@ -97,6 +198,23 @@
|
||||
"noSessions": "未发现会话",
|
||||
"noSessionsDescription": "启动新的问题发现会话以开始",
|
||||
"findingsDetail": "发现详情",
|
||||
"selectSession": "选择会话以查看发现",
|
||||
"sessionId": "会话ID",
|
||||
"name": "名称",
|
||||
"status": "状态",
|
||||
"createdAt": "创建时间",
|
||||
"completedAt": "完成时间",
|
||||
"progress": "进度",
|
||||
"findingsCount": "发现数量",
|
||||
"export": "导出JSON",
|
||||
"exportSelected": "导出选中的 {count} 项",
|
||||
"exporting": "导出中...",
|
||||
"exportAsIssues": "导出为问题",
|
||||
"severityBreakdown": "严重程度分布",
|
||||
"typeBreakdown": "类型分布",
|
||||
"tabFindings": "发现",
|
||||
"tabProgress": "进度",
|
||||
"tabInfo": "会话信息",
|
||||
"stats": {
|
||||
"totalSessions": "总会话数",
|
||||
"completed": "已完成",
|
||||
@@ -124,13 +242,37 @@
|
||||
"critical": "严重",
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低"
|
||||
"low": "低",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"type": {
|
||||
"all": "全部类型"
|
||||
},
|
||||
"exportedStatus": {
|
||||
"all": "全部导出状态",
|
||||
"exported": "已导出",
|
||||
"notExported": "未导出"
|
||||
},
|
||||
"issueStatus": {
|
||||
"all": "全部问题状态",
|
||||
"hasIssue": "已关联问题",
|
||||
"noIssue": "未关联问题"
|
||||
},
|
||||
"noFindings": "未发现结果",
|
||||
"export": "导出"
|
||||
"noFindingsDescription": "没有找到匹配的发现结果",
|
||||
"searchPlaceholder": "搜索发现...",
|
||||
"filterBySeverity": "按严重程度筛选",
|
||||
"filterByType": "按类型筛选",
|
||||
"filterByExported": "按导出状态筛选",
|
||||
"filterByIssue": "按关联问题筛选",
|
||||
"allSeverities": "全部严重程度",
|
||||
"allTypes": "全部类型",
|
||||
"showingCount": "显示 {count} 条发现",
|
||||
"exported": "已导出",
|
||||
"hasIssue": "已关联",
|
||||
"export": "导出",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选"
|
||||
},
|
||||
"tabs": {
|
||||
"findings": "发现",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"prompts": "提示历史",
|
||||
"settings": "设置",
|
||||
"mcp": "MCP 服务器",
|
||||
"codexlens": "CodexLens",
|
||||
"endpoints": "CLI 端点",
|
||||
"installations": "安装",
|
||||
"help": "帮助",
|
||||
|
||||
@@ -6,13 +6,25 @@
|
||||
"tabs": {
|
||||
"tasks": "任务",
|
||||
"context": "上下文",
|
||||
"summary": "摘要"
|
||||
"summary": "摘要",
|
||||
"implPlan": "IMPL 计划",
|
||||
"conflict": "冲突",
|
||||
"review": "审查"
|
||||
},
|
||||
"tasks": {
|
||||
"completed": "已完成",
|
||||
"inProgress": "进行中",
|
||||
"pending": "待处理",
|
||||
"blocked": "已阻塞",
|
||||
"quickActions": {
|
||||
"markAllPending": "全部待处理",
|
||||
"markAllInProgress": "全部进行中",
|
||||
"markAllCompleted": "全部已完成"
|
||||
},
|
||||
"statusUpdate": {
|
||||
"success": "任务状态更新成功",
|
||||
"error": "更新任务状态失败"
|
||||
},
|
||||
"status": {
|
||||
"pending": "待处理",
|
||||
"inProgress": "进行中",
|
||||
@@ -36,6 +48,59 @@
|
||||
"empty": {
|
||||
"title": "暂无上下文",
|
||||
"message": "该会话暂无上下文信息。"
|
||||
},
|
||||
"explorations": {
|
||||
"title": "探索结果",
|
||||
"angles": "个角度",
|
||||
"projectStructure": "项目结构",
|
||||
"relevantFiles": "相关文件",
|
||||
"patterns": "模式",
|
||||
"dependencies": "依赖关系",
|
||||
"integrationPoints": "集成点",
|
||||
"testing": "测试"
|
||||
},
|
||||
"categories": {
|
||||
"documentation": "文档",
|
||||
"sourceCode": "源代码",
|
||||
"tests": "测试"
|
||||
},
|
||||
"assets": {
|
||||
"title": "资源",
|
||||
"noData": "未找到资源",
|
||||
"scope": "范围",
|
||||
"contains": "包含"
|
||||
},
|
||||
"dependencies": {
|
||||
"title": "依赖",
|
||||
"internal": "内部依赖",
|
||||
"external": "外部依赖",
|
||||
"from": "来源",
|
||||
"to": "目标",
|
||||
"type": "类型"
|
||||
},
|
||||
"testContext": {
|
||||
"title": "测试上下文",
|
||||
"tests": "个测试",
|
||||
"existingTests": "个现有测试",
|
||||
"markers": "个标记",
|
||||
"coverage": "覆盖率配置",
|
||||
"backend": "后端",
|
||||
"frontend": "前端",
|
||||
"framework": "框架"
|
||||
},
|
||||
"conflictDetection": {
|
||||
"title": "冲突检测",
|
||||
"riskLevel": {
|
||||
"low": "低风险",
|
||||
"medium": "中等风险",
|
||||
"high": "高风险",
|
||||
"critical": "严重风险"
|
||||
},
|
||||
"mitigation": "缓解策略",
|
||||
"riskFactors": "风险因素",
|
||||
"testGaps": "测试缺失",
|
||||
"existingImplementations": "现有实现",
|
||||
"affectedModules": "受影响模块"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
@@ -45,6 +110,50 @@
|
||||
"message": "该会话暂无摘要。"
|
||||
}
|
||||
},
|
||||
"implPlan": {
|
||||
"title": "实现计划",
|
||||
"empty": {
|
||||
"title": "暂无 IMPL 计划",
|
||||
"message": "该会话暂无实现计划。"
|
||||
},
|
||||
"viewFull": "查看完整计划({count} 行)"
|
||||
},
|
||||
"conflict": {
|
||||
"title": "冲突解决",
|
||||
"comingSoon": "冲突解决(即将推出)",
|
||||
"comingSoonMessage": "此标签页将显示冲突解决决策和用户选择。",
|
||||
"empty": {
|
||||
"title": "暂无冲突解决数据",
|
||||
"message": "该会话暂无冲突解决信息。"
|
||||
},
|
||||
"resolvedAt": "已解决",
|
||||
"userDecisions": "用户决策",
|
||||
"description": "描述",
|
||||
"implications": "影响",
|
||||
"resolvedConflicts": "已解决冲突",
|
||||
"strategy": "策略"
|
||||
},
|
||||
"review": {
|
||||
"title": "代码审查",
|
||||
"comingSoon": "代码审查(即将推出)",
|
||||
"comingSoonMessage": "此标签页将显示审查结果和建议。",
|
||||
"empty": {
|
||||
"title": "暂无审查数据",
|
||||
"message": "该会话暂无审查信息。"
|
||||
},
|
||||
"noFindings": {
|
||||
"title": "未发现审查结果",
|
||||
"message": "没有匹配当前严重程度筛选器的审查结果。"
|
||||
},
|
||||
"filterBySeverity": "按严重程度筛选",
|
||||
"severity": {
|
||||
"all": "全部严重程度",
|
||||
"critical": "严重",
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"created": "创建时间",
|
||||
"updated": "更新时间",
|
||||
|
||||
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal file
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
// ========================================
|
||||
// CodexLens Manager Page Tests
|
||||
// ========================================
|
||||
// Integration tests for CodexLens manager page with tabs
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
import * as api from '@/lib/api';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
fetchCodexLensDashboardInit: vi.fn(),
|
||||
bootstrapCodexLens: vi.fn(),
|
||||
uninstallCodexLens: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
toasts: [],
|
||||
wsStatus: 'disconnected' as const,
|
||||
wsLastMessage: null,
|
||||
isWsConnected: false,
|
||||
addToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearAllToasts: vi.fn(),
|
||||
connectWebSocket: vi.fn(),
|
||||
disconnectWebSocket: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the mutations hook separately
|
||||
vi.mock('@/hooks/useCodexLens', async () => {
|
||||
return {
|
||||
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
|
||||
useCodexLensMutations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock window.confirm
|
||||
global.confirm = vi.fn(() => true);
|
||||
|
||||
const mockDashboardData = {
|
||||
installed: true,
|
||||
status: {
|
||||
ready: true,
|
||||
installed: true,
|
||||
version: '1.0.0',
|
||||
pythonVersion: '3.11.0',
|
||||
venvPath: '/path/to/venv',
|
||||
},
|
||||
config: {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
},
|
||||
semantic: { available: true },
|
||||
};
|
||||
|
||||
const mockMutations = {
|
||||
bootstrap: vi.fn().mockResolvedValue({ success: true }),
|
||||
uninstall: vi.fn().mockResolvedValue({ success: true }),
|
||||
isBootstrapping: false,
|
||||
isUninstalling: false,
|
||||
};
|
||||
|
||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
||||
|
||||
describe('CodexLensManagerPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('when installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should render page title and description', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all tabs', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Models/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show uninstall button when installed', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch between tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const settingsTab = screen.getByText(/Settings/i);
|
||||
await user.click(settingsTab);
|
||||
|
||||
expect(settingsTab).toHaveAttribute('data-state', 'active');
|
||||
});
|
||||
|
||||
it('should call refresh on button click', async () => {
|
||||
const refetch = vi.fn();
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should show bootstrap button', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show not installed alert', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call bootstrap on button click', async () => {
|
||||
const bootstrap = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
bootstrap,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const bootstrapButton = screen.getByText(/Bootstrap/i);
|
||||
await user.click(bootstrapButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bootstrap).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show confirmation dialog on uninstall', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
|
||||
});
|
||||
|
||||
it('should call uninstall when confirmed', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uninstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call uninstall when cancelled', async () => {
|
||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
expect(uninstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading skeleton when loading', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
// Check for skeleton or loading indicator
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
expect(refreshButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable refresh button when fetching', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
expect(refreshButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n - Chinese locale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should display translated text in Chinese', () => {
|
||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/概览/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/设置/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/高级/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display translated uninstall button', () => {
|
||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error states', () => {
|
||||
it('should handle API errors gracefully', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: new Error('API Error'),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
// Page should still render even with error
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal file
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// ========================================
|
||||
// CodexLens Manager Page
|
||||
// ========================================
|
||||
// Manage CodexLens semantic code search with tabbed interface
|
||||
// Supports Overview, Settings, Models, and Advanced tabs
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { OverviewTab } from '@/components/codexlens/OverviewTab';
|
||||
import { SettingsTab } from '@/components/codexlens/SettingsTab';
|
||||
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
|
||||
import { GpuSelector } from '@/components/codexlens/GpuSelector';
|
||||
import { ModelsTab } from '@/components/codexlens/ModelsTab';
|
||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CodexLensManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
installed,
|
||||
status,
|
||||
config,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useCodexLensDashboard();
|
||||
|
||||
const {
|
||||
bootstrap,
|
||||
isBootstrapping,
|
||||
uninstall,
|
||||
isUninstalling,
|
||||
} = useCodexLensMutations();
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleBootstrap = async () => {
|
||||
const result = await bootstrap();
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
const result = await uninstall();
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
setIsUninstallDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'codexlens.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'codexlens.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
{!installed ? (
|
||||
<Button
|
||||
onClick={handleBootstrap}
|
||||
disabled={isBootstrapping}
|
||||
>
|
||||
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
|
||||
{isBootstrapping
|
||||
? formatMessage({ id: 'codexlens.bootstrapping' })
|
||||
: formatMessage({ id: 'codexlens.bootstrap' })
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
|
||||
{isUninstalling
|
||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
||||
: formatMessage({ id: 'codexlens.uninstall' })
|
||||
}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({ id: 'codexlens.confirmUninstall' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isUninstalling}>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
disabled={isUninstalling}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isUninstalling
|
||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
||||
: formatMessage({ id: 'common.actions.confirm' })
|
||||
}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Status Alert */}
|
||||
{!installed && !isLoading && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
<p className="text-sm text-warning-foreground">
|
||||
{formatMessage({ id: 'codexlens.notInstalled' })}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabbed Interface */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
{formatMessage({ id: 'codexlens.tabs.overview' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
{formatMessage({ id: 'codexlens.tabs.settings' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
{formatMessage({ id: 'codexlens.tabs.models' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{formatMessage({ id: 'codexlens.tabs.advanced' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab
|
||||
installed={installed}
|
||||
status={status}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab enabled={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models">
|
||||
<ModelsTab installed={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<AdvancedTab enabled={installed} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexLensManagerPage;
|
||||
@@ -25,6 +25,8 @@ export function DiscoveryPage() {
|
||||
setFilters,
|
||||
selectSession,
|
||||
exportFindings,
|
||||
exportSelectedFindings,
|
||||
isExporting,
|
||||
} = useIssueDiscovery({ refetchInterval: 3000 });
|
||||
|
||||
if (error) {
|
||||
@@ -163,6 +165,8 @@ export function DiscoveryPage() {
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
onExport={exportFindings}
|
||||
onExportSelected={exportSelectedFindings}
|
||||
isExporting={isExporting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,28 +3,219 @@
|
||||
// ========================================
|
||||
// Unified page for issues, queue, and discovery with tab navigation
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Github,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
|
||||
import { pullIssuesFromGitHub } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
isCreating: boolean;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueHubPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
|
||||
|
||||
// Issues data
|
||||
const { refetch: refetchIssues, isFetching: isFetchingIssues } = useIssues();
|
||||
// Queue data
|
||||
const { refetch: refetchQueue, isFetching: isFetchingQueue } = useIssueQueue();
|
||||
|
||||
const { createIssue, isCreating } = useIssueMutations();
|
||||
|
||||
const setCurrentTab = (tab: IssueTab) => {
|
||||
setSearchParams({ tab });
|
||||
};
|
||||
|
||||
// Issues tab handlers
|
||||
const handleIssuesRefresh = useCallback(() => {
|
||||
refetchIssues();
|
||||
}, [refetchIssues]);
|
||||
|
||||
const handleGithubSync = useCallback(async () => {
|
||||
setIsGithubSyncing(true);
|
||||
try {
|
||||
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
|
||||
console.log('GitHub sync result:', result);
|
||||
await refetchIssues();
|
||||
} catch (error) {
|
||||
console.error('GitHub sync failed:', error);
|
||||
} finally {
|
||||
setIsGithubSyncing(false);
|
||||
}
|
||||
}, [refetchIssues]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
};
|
||||
|
||||
// Queue tab handler
|
||||
const handleQueueRefresh = useCallback(() => {
|
||||
refetchQueue();
|
||||
}, [refetchQueue]);
|
||||
|
||||
// Render action buttons based on current tab
|
||||
const renderActionButtons = () => {
|
||||
switch (currentTab) {
|
||||
case 'issues':
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingIssues && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGithubSync} disabled={isGithubSyncing}>
|
||||
<Github className={cn('w-4 h-4 mr-2', isGithubSyncing && 'animate-spin')} />
|
||||
{formatMessage({ id: 'issues.actions.github' })}
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.create' })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'queue':
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleQueueRefresh} disabled={isFetchingQueue}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingQueue && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'discovery':
|
||||
return null; // Discovery panel has its own controls
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<IssueHubHeader currentTab={currentTab} />
|
||||
{/* Header and action buttons on same row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<IssueHubHeader currentTab={currentTab} />
|
||||
|
||||
{/* Action buttons - dynamic based on current tab */}
|
||||
{renderActionButtons() && (
|
||||
<div className="flex gap-2">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||
{currentTab === 'issues' && <IssuesPanel />}
|
||||
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
|
||||
{currentTab === 'queue' && <QueuePanel />}
|
||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||
|
||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ========================================
|
||||
// SessionDetailPage Component
|
||||
// ========================================
|
||||
// Session detail page with tabs for tasks, context, and summary
|
||||
// Session detail page with tabs for tasks, context, summary, impl-plan, conflict, and review
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -13,18 +13,24 @@ import {
|
||||
Package,
|
||||
FileText,
|
||||
XCircle,
|
||||
Ruler,
|
||||
Scale,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useSessionDetail } from '@/hooks/useSessionDetail';
|
||||
import { TaskListTab } from './session-detail/TaskListTab';
|
||||
import { ContextTab } from './session-detail/ContextTab';
|
||||
import { SummaryTab } from './session-detail/SummaryTab';
|
||||
import ImplPlanTab from './session-detail/ImplPlanTab';
|
||||
import { ConflictTab } from './session-detail/ConflictTab';
|
||||
import { ReviewTab } from './session-detail/ReviewTab';
|
||||
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import type { TaskData } from '@/types/store';
|
||||
|
||||
type TabValue = 'tasks' | 'context' | 'summary';
|
||||
type TabValue = 'tasks' | 'context' | 'summary' | 'impl-plan' | 'conflict' | 'review';
|
||||
|
||||
/**
|
||||
* SessionDetailPage component - Main session detail page with tabs
|
||||
@@ -92,9 +98,10 @@ export function SessionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { session, context, summary } = sessionDetail;
|
||||
const { session, context, summary, summaries, implPlan, conflicts, review } = sessionDetail;
|
||||
const tasks = session.tasks || [];
|
||||
const completedTasks = tasks.filter((t) => t.status === 'completed').length;
|
||||
const hasReview = session.has_review || session.review;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -158,6 +165,20 @@ export function SessionDetailPage() {
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="impl-plan">
|
||||
<Ruler className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="conflict">
|
||||
<Scale className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.conflict' })}
|
||||
</TabsTrigger>
|
||||
{hasReview && (
|
||||
<TabsTrigger value="review">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.review' })}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
@@ -169,8 +190,22 @@ export function SessionDetailPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="summary" className="mt-4">
|
||||
<SummaryTab summary={summary} />
|
||||
<SummaryTab summary={summary} summaries={summaries} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="impl-plan" className="mt-4">
|
||||
<ImplPlanTab implPlan={implPlan} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="conflict" className="mt-4">
|
||||
<ConflictTab conflicts={conflicts as any} />
|
||||
</TabsContent>
|
||||
|
||||
{hasReview && (
|
||||
<TabsContent value="review" className="mt-4">
|
||||
<ReviewTab review={review as any} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Description (if exists) */}
|
||||
|
||||
@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
|
||||
export { PromptHistoryPage } from './PromptHistoryPage';
|
||||
export { ExplorerPage } from './ExplorerPage';
|
||||
export { GraphExplorerPage } from './GraphExplorerPage';
|
||||
export { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
|
||||
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal file
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// ========================================
|
||||
// ConflictTab Component
|
||||
// ========================================
|
||||
// Conflict tab for session detail page - displays conflict resolution decisions
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Scale,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/Collapsible';
|
||||
|
||||
// Type definitions for conflict resolution data
|
||||
export interface UserDecision {
|
||||
choice: string;
|
||||
description?: string;
|
||||
implications?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConflict {
|
||||
id: string;
|
||||
category?: string;
|
||||
brief?: string;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionData {
|
||||
session_id: string;
|
||||
resolved_at?: string;
|
||||
user_decisions?: Record<string, UserDecision>;
|
||||
resolved_conflicts?: ResolvedConflict[];
|
||||
}
|
||||
|
||||
export interface ConflictTabProps {
|
||||
conflicts?: ConflictResolutionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConflictTab component - Display conflict resolution decisions
|
||||
*/
|
||||
export function ConflictTab({ conflicts }: ConflictTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!conflicts) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUserDecisions = conflicts.user_decisions && Object.keys(conflicts.user_decisions).length > 0;
|
||||
const hasResolvedConflicts = conflicts.resolved_conflicts && conflicts.resolved_conflicts.length > 0;
|
||||
|
||||
if (!hasUserDecisions && !hasResolvedConflicts) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Resolved At */}
|
||||
{conflicts.resolved_at && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<span>
|
||||
{formatMessage({ id: 'sessionDetail.conflict.resolvedAt' })}:{' '}
|
||||
{new Date(conflicts.resolved_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Decisions Section */}
|
||||
{hasUserDecisions && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.userDecisions' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(conflicts.user_decisions!).map(([key, decision], index) => (
|
||||
<Collapsible key={key} defaultOpen={index < 3}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm flex-1">{key}</span>
|
||||
<Badge variant="success" className="text-xs">
|
||||
{decision.choice}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pr-3 pb-3 space-y-2">
|
||||
{decision.description && (
|
||||
<div className="text-sm text-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.description' })}:</span>{' '}
|
||||
{decision.description}
|
||||
</div>
|
||||
)}
|
||||
{decision.implications && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.implications' })}:</span>{' '}
|
||||
{decision.implications}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resolved Conflicts Section */}
|
||||
{hasResolvedConflicts && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.resolvedConflicts' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{conflicts.resolved_conflicts!.map((conflict) => (
|
||||
<Collapsible key={conflict.id}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<ChevronDown className="h-4 w-4 transition-transform data-[state=closed]:rotate-[-90deg]" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{conflict.id}</span>
|
||||
{conflict.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{conflict.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{conflict.brief && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{conflict.brief}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pr-3 pb-3">
|
||||
{conflict.strategy && (
|
||||
<div className="text-sm text-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.strategy' })}:</span>{' '}
|
||||
{conflict.strategy}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { SessionDetailContext } from '@/lib/api';
|
||||
import {
|
||||
ExplorationsSection,
|
||||
AssetsCard,
|
||||
DependenciesCard,
|
||||
TestContextCard,
|
||||
ConflictDetectionCard,
|
||||
} from '@/components/session-detail/context';
|
||||
|
||||
export interface ContextTabProps {
|
||||
context?: SessionDetailContext;
|
||||
@@ -44,12 +51,16 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0;
|
||||
const hasArtifacts = context.artifacts && context.artifacts.length > 0;
|
||||
const hasSharedContext = context.shared_context;
|
||||
const hasExtendedContext = context.context;
|
||||
const hasExplorations = context.explorations;
|
||||
|
||||
if (
|
||||
!hasRequirements &&
|
||||
!hasFocusPaths &&
|
||||
!hasArtifacts &&
|
||||
!hasSharedContext
|
||||
!hasSharedContext &&
|
||||
!hasExtendedContext &&
|
||||
!hasExplorations
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
@@ -66,7 +77,7 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Requirements */}
|
||||
{/* Original Context Sections - Maintained for backward compatibility */}
|
||||
{hasRequirements && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -90,7 +101,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Focus Paths */}
|
||||
{hasFocusPaths && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -113,7 +123,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Artifacts */}
|
||||
{hasArtifacts && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -133,7 +142,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Shared Context */}
|
||||
{hasSharedContext && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -142,7 +150,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
{formatMessage({ id: 'sessionDetail.context.sharedContext' })}
|
||||
</h3>
|
||||
|
||||
{/* Tech Stack */}
|
||||
{context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
@@ -158,7 +165,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conventions */}
|
||||
{context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
@@ -177,6 +183,25 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New Extended Context Sections from context-package.json */}
|
||||
{hasExplorations && <ExplorationsSection data={context.explorations} />}
|
||||
|
||||
{hasExtendedContext && context.context!.assets && (
|
||||
<AssetsCard data={context.context!.assets} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.dependencies && (
|
||||
<DependenciesCard data={context.context!.dependencies} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.test_context && (
|
||||
<TestContextCard data={context.context!.test_context} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.conflict_detection && (
|
||||
<ConflictDetectionCard data={context.context!.conflict_detection} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal file
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// ========================================
|
||||
// ImplPlanTab Component
|
||||
// ========================================
|
||||
// IMPL Plan tab for session detail page
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Ruler, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export interface ImplPlanTabProps {
|
||||
implPlan?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Component
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* ImplPlanTab component - Display IMPL_PLAN.md content with modal viewer
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ImplPlanTab
|
||||
* implPlan="# Implementation Plan\n\n## Steps..."}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ImplPlanTab({ implPlan }: ImplPlanTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
|
||||
if (!implPlan) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Ruler className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get preview (first 5 lines)
|
||||
const lines = implPlan.split('\n');
|
||||
const preview = lines.slice(0, 5).join('\n');
|
||||
const hasMore = lines.length > 5;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ruler className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.title' })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.view' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{preview}{hasMore && '\n...'}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.viewFull' }, { count: lines.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal Viewer */}
|
||||
<MarkdownModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="IMPL_PLAN.md"
|
||||
content={implPlan}
|
||||
contentType="markdown"
|
||||
maxWidth="3xl"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exports
|
||||
// ========================================
|
||||
|
||||
export default ImplPlanTab;
|
||||
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal file
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// ========================================
|
||||
// ReviewTab Component
|
||||
// ========================================
|
||||
// Review tab for session detail page - displays review findings by dimension
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/Collapsible';
|
||||
|
||||
// Type definitions for review data
|
||||
export interface ReviewFinding {
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ReviewDimension {
|
||||
name: string;
|
||||
findings?: ReviewFinding[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ReviewTabProps {
|
||||
review?: {
|
||||
dimensions?: ReviewDimension[];
|
||||
};
|
||||
}
|
||||
|
||||
type SeverityFilter = 'all' | 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* Get severity color variant for badges
|
||||
*/
|
||||
function getSeverityVariant(severity: string): 'destructive' | 'warning' | 'default' | 'secondary' {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'destructive';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'default';
|
||||
case 'low':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color class for severity
|
||||
*/
|
||||
function getSeverityBorderClass(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'border-destructive';
|
||||
case 'high':
|
||||
return 'border-orange-500';
|
||||
case 'medium':
|
||||
return 'border-yellow-500';
|
||||
case 'low':
|
||||
return 'border-blue-500';
|
||||
default:
|
||||
return 'border-border';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity icon
|
||||
*/
|
||||
function getSeverityIcon(severity: string) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
case 'low':
|
||||
return <Info className="h-4 w-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewTab component - Display review findings by dimension
|
||||
*/
|
||||
export function ReviewTab({ review }: ReviewTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
|
||||
|
||||
if (!review || !review.dimensions || review.dimensions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.review.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter findings by severity
|
||||
const filteredDimensions = review.dimensions.map((dimension) => ({
|
||||
...dimension,
|
||||
findings: dimension.findings?.filter((finding) =>
|
||||
severityFilter === 'all' || finding.severity === severityFilter
|
||||
),
|
||||
})).filter((dimension) => dimension.findings && dimension.findings.length > 0);
|
||||
|
||||
const hasFindings = filteredDimensions.some((d) => d.findings && d.findings.length > 0);
|
||||
|
||||
if (!hasFindings) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.review.noFindings.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.noFindings.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Severity Filter */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.filterBySeverity' })}
|
||||
</span>
|
||||
<Select value={severityFilter} onValueChange={(v) => setSeverityFilter(v as SeverityFilter)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'sessionDetail.review.severity.all' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'sessionDetail.review.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'sessionDetail.review.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'sessionDetail.review.severity.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'sessionDetail.review.severity.low' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Dimensions with Findings */}
|
||||
{filteredDimensions.map((dimension) => {
|
||||
if (!dimension.findings || dimension.findings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={dimension.name}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">{dimension.name}</h3>
|
||||
<Badge variant="secondary">{dimension.findings.length}</Badge>
|
||||
</div>
|
||||
|
||||
{dimension.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{dimension.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{dimension.findings.map((finding, findingIndex) => (
|
||||
<Collapsible key={`${dimension.name}-${findingIndex}`} className={`border-l-4 ${getSeverityBorderClass(finding.severity)} rounded-r-lg`}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 hover:bg-accent/50 transition-colors">
|
||||
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(finding.severity)}
|
||||
<span className="font-medium text-sm">{finding.title}</span>
|
||||
<Badge variant={getSeverityVariant(finding.severity)} className="text-xs">
|
||||
{formatMessage({ id: `sessionDetail.review.severity.${finding.severity}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
{finding.location && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{finding.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-6 pb-3 space-y-2">
|
||||
{finding.description && (
|
||||
<div className="text-sm text-foreground">
|
||||
{finding.description}
|
||||
</div>
|
||||
)}
|
||||
{finding.code && (
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
|
||||
<code>{finding.code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,60 @@
|
||||
// ========================================
|
||||
// SummaryTab Component
|
||||
// ========================================
|
||||
// Summary tab for session detail page
|
||||
// Summary tab for session detail page with multiple summaries support
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { FileText, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export interface SummaryItem {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SummaryTabProps {
|
||||
summary?: string;
|
||||
summaries?: SummaryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SummaryTab component - Display session summary
|
||||
*/
|
||||
export function SummaryTab({ summary }: SummaryTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
// ========================================
|
||||
// Component
|
||||
// ========================================
|
||||
|
||||
if (!summary) {
|
||||
/**
|
||||
* SummaryTab component - Display session summary/summaries with modal viewer
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SummaryTab
|
||||
* summaries={[{ name: 'Plan Summary', content: '...' }]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SummaryTab({ summary, summaries }: SummaryTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedSummary, setSelectedSummary] = React.useState<SummaryItem | null>(null);
|
||||
|
||||
// Use summaries array if available, otherwise fallback to single summary
|
||||
const summaryList: SummaryItem[] = React.useMemo(() => {
|
||||
if (summaries && summaries.length > 0) {
|
||||
return summaries;
|
||||
}
|
||||
if (summary) {
|
||||
return [{ name: formatMessage({ id: 'sessionDetail.summary.default' }), content: summary }];
|
||||
}
|
||||
return [];
|
||||
}, [summaries, summary, formatMessage]);
|
||||
|
||||
if (summaryList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -32,16 +69,97 @@ export function SummaryTab({ summary }: SummaryTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.summary.title' })}
|
||||
</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
<p className="whitespace-pre-wrap">{summary}</p>
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{summaryList.length === 1 ? (
|
||||
// Single summary - inline display
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summaryList[0].name}
|
||||
</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
<p className="whitespace-pre-wrap">{summaryList[0].content}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
// Multiple summaries - card list with modal viewer
|
||||
summaryList.map((item, index) => (
|
||||
<SummaryCard
|
||||
key={index}
|
||||
summary={item}
|
||||
onClick={() => setSelectedSummary(item)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Viewer */}
|
||||
<MarkdownModal
|
||||
isOpen={!!selectedSummary}
|
||||
onClose={() => setSelectedSummary(null)}
|
||||
title={selectedSummary?.name || ''}
|
||||
content={selectedSummary?.content || ''}
|
||||
contentType="markdown"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Sub-Components
|
||||
// ========================================
|
||||
|
||||
interface SummaryCardProps {
|
||||
summary: SummaryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Get preview (first 3 lines)
|
||||
const lines = summary.content.split('\n');
|
||||
const preview = lines.slice(0, 3).join('\n');
|
||||
const hasMore = lines.length > 3;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summary.name}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.view' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{preview}{hasMore && '\n...'}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary">
|
||||
{lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exports
|
||||
// ========================================
|
||||
|
||||
export default SummaryTab;
|
||||
|
||||
@@ -3,51 +3,27 @@
|
||||
// ========================================
|
||||
// Tasks tab for session detail page
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Circle,
|
||||
CheckCircle,
|
||||
Code,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks';
|
||||
import type { SessionMetadata, TaskData } from '@/types/store';
|
||||
import type { TaskStatus } from '@/lib/api';
|
||||
import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
|
||||
|
||||
export interface TaskListTabProps {
|
||||
session: SessionMetadata;
|
||||
onTaskClick?: (task: TaskData) => void;
|
||||
}
|
||||
|
||||
// Status configuration
|
||||
const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
pending: {
|
||||
label: 'sessionDetail.tasks.status.pending',
|
||||
variant: 'secondary',
|
||||
icon: Circle,
|
||||
},
|
||||
in_progress: {
|
||||
label: 'sessionDetail.tasks.status.inProgress',
|
||||
variant: 'warning',
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: 'sessionDetail.tasks.status.completed',
|
||||
variant: 'success',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
blocked: {
|
||||
label: 'sessionDetail.tasks.status.blocked',
|
||||
variant: 'destructive',
|
||||
icon: Circle,
|
||||
},
|
||||
skipped: {
|
||||
label: 'sessionDetail.tasks.status.skipped',
|
||||
variant: 'default',
|
||||
icon: Circle,
|
||||
},
|
||||
};
|
||||
export interface TaskListTabProps {
|
||||
session: SessionMetadata;
|
||||
onTaskClick?: (task: TaskData) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskListTab component - Display tasks in a list format
|
||||
@@ -59,34 +35,129 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter((t) => t.status === 'pending').length;
|
||||
const blocked = tasks.filter((t) => t.status === 'blocked').length;
|
||||
|
||||
// Loading states for bulk actions
|
||||
const [isLoadingPending, setIsLoadingPending] = useState(false);
|
||||
const [isLoadingInProgress, setIsLoadingInProgress] = useState(false);
|
||||
const [isLoadingCompleted, setIsLoadingCompleted] = useState(false);
|
||||
|
||||
// Local task state for optimistic updates
|
||||
const [localTasks, setLocalTasks] = useState<TaskData[]>(tasks);
|
||||
|
||||
// Update local tasks when session tasks change
|
||||
if (tasks !== localTasks && !isLoadingPending && !isLoadingInProgress && !isLoadingCompleted) {
|
||||
setLocalTasks(tasks);
|
||||
}
|
||||
|
||||
// Get session path for API calls
|
||||
const sessionPath = (session as any).path || session.session_id;
|
||||
|
||||
// Bulk action handlers
|
||||
const handleMarkAllPending = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'pending');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingPending(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', error);
|
||||
} finally {
|
||||
setIsLoadingPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllInProgress = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'in_progress');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingInProgress(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
|
||||
} finally {
|
||||
setIsLoadingInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllCompleted = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'completed');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingCompleted(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', error);
|
||||
} finally {
|
||||
setIsLoadingCompleted(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Individual task status change handler
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: TaskStatus) => {
|
||||
const previousTasks = [...localTasks];
|
||||
const previousTask = previousTasks.find((t) => t.task_id === taskId);
|
||||
|
||||
if (!previousTask) return;
|
||||
|
||||
// Optimistic update
|
||||
setLocalTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.task_id === taskId ? { ...t, status: newStatus } : t
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await updateTaskStatus(sessionPath, taskId, newStatus);
|
||||
if (!result.success) {
|
||||
// Rollback on error
|
||||
setLocalTasks(previousTasks);
|
||||
console.error('[TaskListTab] Failed to update task status:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalTasks(previousTasks);
|
||||
console.error('[TaskListTab] Failed to update task status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats Bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border">
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Loader2 className="h-4 w-4 text-warning" />
|
||||
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
|
||||
</span>
|
||||
{blocked > 0 && (
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Circle className="h-4 w-4 text-destructive" />
|
||||
<strong>{blocked}</strong> {formatMessage({ id: 'sessionDetail.tasks.blocked' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Stats Bar with Bulk Actions */}
|
||||
<TaskStatsBar
|
||||
completed={completed}
|
||||
inProgress={inProgress}
|
||||
pending={pending}
|
||||
onMarkAllPending={handleMarkAllPending}
|
||||
onMarkAllInProgress={handleMarkAllInProgress}
|
||||
onMarkAllCompleted={handleMarkAllCompleted}
|
||||
isLoadingPending={isLoadingPending}
|
||||
isLoadingInProgress={isLoadingInProgress}
|
||||
isLoadingCompleted={isLoadingCompleted}
|
||||
/>
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasks.length === 0 ? (
|
||||
{localTasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ListChecks className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
@@ -98,10 +169,7 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task, index) => {
|
||||
const currentStatusConfig = task.status ? taskStatusConfig[task.status] : taskStatusConfig.pending;
|
||||
const StatusIcon = currentStatusConfig.icon;
|
||||
|
||||
{localTasks.map((task, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={task.task_id || index}
|
||||
@@ -111,18 +179,19 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{task.task_id}
|
||||
</span>
|
||||
<Badge variant={currentStatusConfig.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: currentStatusConfig.label })}
|
||||
</Badge>
|
||||
<TaskStatusDropdown
|
||||
currentStatus={task.status as TaskStatus}
|
||||
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
||||
size="sm"
|
||||
/>
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground text-sm">
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PromptHistoryPage,
|
||||
ExplorerPage,
|
||||
GraphExplorerPage,
|
||||
CodexLensManagerPage,
|
||||
} from '@/pages';
|
||||
|
||||
/**
|
||||
@@ -141,6 +142,10 @@ const routes: RouteObject[] = [
|
||||
path: 'settings/rules',
|
||||
element: <RulesManagerPage />,
|
||||
},
|
||||
{
|
||||
path: 'settings/codexlens',
|
||||
element: <CodexLensManagerPage />,
|
||||
},
|
||||
{
|
||||
path: 'help',
|
||||
element: <HelpPage />,
|
||||
@@ -206,6 +211,7 @@ export const ROUTES = {
|
||||
ENDPOINTS: '/settings/endpoints',
|
||||
INSTALLATIONS: '/settings/installations',
|
||||
SETTINGS_RULES: '/settings/rules',
|
||||
CODEXLENS_MANAGER: '/settings/codexlens',
|
||||
HELP: '/help',
|
||||
EXPLORER: '/explorer',
|
||||
GRAPH: '/graph',
|
||||
|
||||
@@ -111,6 +111,84 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
||||
'issues.discovery.status.failed': 'Failed',
|
||||
'issues.discovery.progress': 'Progress',
|
||||
'issues.discovery.findings': 'Findings',
|
||||
// CodexLens
|
||||
'codexlens.title': 'CodexLens',
|
||||
'codexlens.description': 'Semantic code search engine',
|
||||
'codexlens.bootstrap': 'Bootstrap',
|
||||
'codexlens.bootstrapping': 'Bootstrapping...',
|
||||
'codexlens.uninstall': 'Uninstall',
|
||||
'codexlens.uninstalling': 'Uninstalling...',
|
||||
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?',
|
||||
'codexlens.notInstalled': 'CodexLens is not installed',
|
||||
'codexlens.comingSoon': 'Coming Soon',
|
||||
'codexlens.tabs.overview': 'Overview',
|
||||
'codexlens.tabs.settings': 'Settings',
|
||||
'codexlens.tabs.models': 'Models',
|
||||
'codexlens.tabs.advanced': 'Advanced',
|
||||
'codexlens.overview.status.installation': 'Installation Status',
|
||||
'codexlens.overview.status.ready': 'Ready',
|
||||
'codexlens.overview.status.notReady': 'Not Ready',
|
||||
'codexlens.overview.status.version': 'Version',
|
||||
'codexlens.overview.status.indexPath': 'Index Path',
|
||||
'codexlens.overview.status.indexCount': 'Index Count',
|
||||
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed',
|
||||
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.',
|
||||
'codexlens.overview.actions.title': 'Quick Actions',
|
||||
'codexlens.overview.actions.ftsFull': 'FTS Full',
|
||||
'codexlens.overview.actions.ftsFullDesc': 'Rebuild full-text index',
|
||||
'codexlens.overview.actions.ftsIncremental': 'FTS Incremental',
|
||||
'codexlens.overview.actions.ftsIncrementalDesc': 'Incremental update full-text index',
|
||||
'codexlens.overview.actions.vectorFull': 'Vector Full',
|
||||
'codexlens.overview.actions.vectorFullDesc': 'Rebuild vector index',
|
||||
'codexlens.overview.actions.vectorIncremental': 'Vector Incremental',
|
||||
'codexlens.overview.actions.vectorIncrementalDesc': 'Incremental update vector index',
|
||||
'codexlens.overview.venv.title': 'Python Virtual Environment Details',
|
||||
'codexlens.overview.venv.pythonVersion': 'Python Version',
|
||||
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
|
||||
'codexlens.overview.venv.lastCheck': 'Last Check Time',
|
||||
'codexlens.settings.currentCount': 'Current Index Count',
|
||||
'codexlens.settings.currentWorkers': 'Current Workers',
|
||||
'codexlens.settings.currentBatchSize': 'Current Batch Size',
|
||||
'codexlens.settings.configTitle': 'Basic Configuration',
|
||||
'codexlens.settings.indexDir.label': 'Index Directory',
|
||||
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
|
||||
'codexlens.settings.indexDir.hint': 'Directory path for storing code indexes',
|
||||
'codexlens.settings.maxWorkers.label': 'Max Workers',
|
||||
'codexlens.settings.maxWorkers.hint': 'API concurrent processing threads (1-32)',
|
||||
'codexlens.settings.batchSize.label': 'Batch Size',
|
||||
'codexlens.settings.batchSize.hint': 'Number of files processed per batch (1-64)',
|
||||
'codexlens.settings.validation.indexDirRequired': 'Index directory is required',
|
||||
'codexlens.settings.validation.maxWorkersRange': 'Workers must be between 1 and 32',
|
||||
'codexlens.settings.validation.batchSizeRange': 'Batch size must be between 1 and 64',
|
||||
'codexlens.settings.save': 'Save',
|
||||
'codexlens.settings.saving': 'Saving...',
|
||||
'codexlens.settings.reset': 'Reset',
|
||||
'codexlens.settings.saveSuccess': 'Configuration saved',
|
||||
'codexlens.settings.saveFailed': 'Save failed',
|
||||
'codexlens.settings.configUpdated': 'Configuration updated successfully',
|
||||
'codexlens.settings.saveError': 'Error saving configuration',
|
||||
'codexlens.settings.unknownError': 'An unknown error occurred',
|
||||
'codexlens.models.title': 'Model Management',
|
||||
'codexlens.models.searchPlaceholder': 'Search models...',
|
||||
'codexlens.models.downloading': 'Downloading...',
|
||||
'codexlens.models.status.downloaded': 'Downloaded',
|
||||
'codexlens.models.status.available': 'Available',
|
||||
'codexlens.models.types.embedding': 'Embedding Models',
|
||||
'codexlens.models.types.reranker': 'Reranker Models',
|
||||
'codexlens.models.filters.label': 'Filter',
|
||||
'codexlens.models.filters.all': 'All',
|
||||
'codexlens.models.actions.download': 'Download',
|
||||
'codexlens.models.actions.delete': 'Delete',
|
||||
'codexlens.models.actions.cancel': 'Cancel',
|
||||
'codexlens.models.custom.title': 'Custom Model',
|
||||
'codexlens.models.custom.placeholder': 'HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)',
|
||||
'codexlens.models.custom.description': 'Download custom models from HuggingFace. Ensure the model name is correct.',
|
||||
'codexlens.models.deleteConfirm': 'Are you sure you want to delete model {modelName}?',
|
||||
'codexlens.models.notInstalled.title': 'CodexLens Not Installed',
|
||||
'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.',
|
||||
'codexlens.models.empty.title': 'No models found',
|
||||
'codexlens.models.empty.description': 'Try adjusting your search or filter criteria',
|
||||
'navigation.codexlens': 'CodexLens',
|
||||
},
|
||||
zh: {
|
||||
// Common
|
||||
@@ -210,6 +288,84 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
||||
'issues.discovery.status.failed': '失败',
|
||||
'issues.discovery.progress': '进度',
|
||||
'issues.discovery.findings': '发现',
|
||||
// CodexLens
|
||||
'codexlens.title': 'CodexLens',
|
||||
'codexlens.description': '语义代码搜索引擎',
|
||||
'codexlens.bootstrap': '引导安装',
|
||||
'codexlens.bootstrapping': '安装中...',
|
||||
'codexlens.uninstall': '卸载',
|
||||
'codexlens.uninstalling': '卸载中...',
|
||||
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?',
|
||||
'codexlens.notInstalled': 'CodexLens 尚未安装',
|
||||
'codexlens.comingSoon': '即将推出',
|
||||
'codexlens.tabs.overview': '概览',
|
||||
'codexlens.tabs.settings': '设置',
|
||||
'codexlens.tabs.models': '模型',
|
||||
'codexlens.tabs.advanced': '高级',
|
||||
'codexlens.overview.status.installation': '安装状态',
|
||||
'codexlens.overview.status.ready': '就绪',
|
||||
'codexlens.overview.status.notReady': '未就绪',
|
||||
'codexlens.overview.status.version': '版本',
|
||||
'codexlens.overview.status.indexPath': '索引路径',
|
||||
'codexlens.overview.status.indexCount': '索引数量',
|
||||
'codexlens.overview.notInstalled.title': 'CodexLens 未安装',
|
||||
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。',
|
||||
'codexlens.overview.actions.title': '快速操作',
|
||||
'codexlens.overview.actions.ftsFull': 'FTS 全量',
|
||||
'codexlens.overview.actions.ftsFullDesc': '重建全文索引',
|
||||
'codexlens.overview.actions.ftsIncremental': 'FTS 增量',
|
||||
'codexlens.overview.actions.ftsIncrementalDesc': '增量更新全文索引',
|
||||
'codexlens.overview.actions.vectorFull': '向量全量',
|
||||
'codexlens.overview.actions.vectorFullDesc': '重建向量索引',
|
||||
'codexlens.overview.actions.vectorIncremental': '向量增量',
|
||||
'codexlens.overview.actions.vectorIncrementalDesc': '增量更新向量索引',
|
||||
'codexlens.overview.venv.title': 'Python 虚拟环境详情',
|
||||
'codexlens.overview.venv.pythonVersion': 'Python 版本',
|
||||
'codexlens.overview.venv.venvPath': '虚拟环境路径',
|
||||
'codexlens.overview.venv.lastCheck': '最后检查时间',
|
||||
'codexlens.settings.currentCount': '当前索引数量',
|
||||
'codexlens.settings.currentWorkers': '当前工作线程',
|
||||
'codexlens.settings.currentBatchSize': '当前批次大小',
|
||||
'codexlens.settings.configTitle': '基本配置',
|
||||
'codexlens.settings.indexDir.label': '索引目录',
|
||||
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
|
||||
'codexlens.settings.indexDir.hint': '存储代码索引的目录路径',
|
||||
'codexlens.settings.maxWorkers.label': '最大工作线程',
|
||||
'codexlens.settings.maxWorkers.hint': 'API 并发处理线程数 (1-32)',
|
||||
'codexlens.settings.batchSize.label': '批次大小',
|
||||
'codexlens.settings.batchSize.hint': '每次批量处理的文件数量 (1-64)',
|
||||
'codexlens.settings.validation.indexDirRequired': '索引目录不能为空',
|
||||
'codexlens.settings.validation.maxWorkersRange': '工作线程数必须在 1-32 之间',
|
||||
'codexlens.settings.validation.batchSizeRange': '批次大小必须在 1-64 之间',
|
||||
'codexlens.settings.save': '保存',
|
||||
'codexlens.settings.saving': '保存中...',
|
||||
'codexlens.settings.reset': '重置',
|
||||
'codexlens.settings.saveSuccess': '配置已保存',
|
||||
'codexlens.settings.saveFailed': '保存失败',
|
||||
'codexlens.settings.configUpdated': '配置更新成功',
|
||||
'codexlens.settings.saveError': '保存配置时出错',
|
||||
'codexlens.settings.unknownError': '发生未知错误',
|
||||
'codexlens.models.title': '模型管理',
|
||||
'codexlens.models.searchPlaceholder': '搜索模型...',
|
||||
'codexlens.models.downloading': '下载中...',
|
||||
'codexlens.models.status.downloaded': '已下载',
|
||||
'codexlens.models.status.available': '可用',
|
||||
'codexlens.models.types.embedding': '嵌入模型',
|
||||
'codexlens.models.types.reranker': '重排序模型',
|
||||
'codexlens.models.filters.label': '筛选',
|
||||
'codexlens.models.filters.all': '全部',
|
||||
'codexlens.models.actions.download': '下载',
|
||||
'codexlens.models.actions.delete': '删除',
|
||||
'codexlens.models.actions.cancel': '取消',
|
||||
'codexlens.models.custom.title': '自定义模型',
|
||||
'codexlens.models.custom.placeholder': 'HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)',
|
||||
'codexlens.models.custom.description': '从 HuggingFace 下载自定义模型。请确保模型名称正确。',
|
||||
'codexlens.models.deleteConfirm': '确定要删除模型 {modelName} 吗?',
|
||||
'codexlens.models.notInstalled.title': 'CodexLens 未安装',
|
||||
'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。',
|
||||
'codexlens.models.empty.title': '没有找到模型',
|
||||
'codexlens.models.empty.description': '尝试调整搜索或筛选条件',
|
||||
'navigation.codexlens': 'CodexLens',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
445
ccw/frontend/tests/e2e/codexlens-manager.spec.ts
Normal file
445
ccw/frontend/tests/e2e/codexlens-manager.spec.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
// ========================================
|
||||
// E2E Tests: CodexLens Manager
|
||||
// ========================================
|
||||
// End-to-end tests for CodexLens management feature
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { setupEnhancedMonitoring } from './helpers/i18n-helpers';
|
||||
|
||||
test.describe('[CodexLens Manager] - CodexLens Management Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'networkidle' as const });
|
||||
});
|
||||
|
||||
test('L4.1 - should navigate to CodexLens manager', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
// Navigate to CodexLens page
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Check page title
|
||||
const title = page.getByText(/CodexLens/i).or(page.getByRole('heading', { name: /CodexLens/i }));
|
||||
await expect(title).toBeVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.2 - should display all tabs', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Check for tabs
|
||||
const tabs = ['Overview', 'Settings', 'Models', 'Advanced'];
|
||||
for (const tab of tabs) {
|
||||
const tabElement = page.getByRole('tab', { name: new RegExp(tab, 'i') });
|
||||
const isVisible = await tabElement.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(tabElement).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.3 - should switch between tabs', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Click Settings tab
|
||||
const settingsTab = page.getByRole('tab', { name: /Settings/i });
|
||||
const settingsVisible = await settingsTab.isVisible().catch(() => false);
|
||||
if (settingsVisible) {
|
||||
await settingsTab.click();
|
||||
// Verify tab is active
|
||||
await expect(settingsTab).toHaveAttribute('data-state', 'active');
|
||||
}
|
||||
|
||||
// Click Models tab
|
||||
const modelsTab = page.getByRole('tab', { name: /Models/i });
|
||||
const modelsVisible = await modelsTab.isVisible().catch(() => false);
|
||||
if (modelsVisible) {
|
||||
await modelsTab.click();
|
||||
await expect(modelsTab).toHaveAttribute('data-state', 'active');
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.4 - should display overview status cards when installed', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for status cards
|
||||
const statusLabels = ['Installation Status', 'Version', 'Index Path', 'Index Count'];
|
||||
for (const label of statusLabels) {
|
||||
const element = page.getByText(new RegExp(label, 'i'));
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(element).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.5 - should display quick action buttons', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for quick action buttons
|
||||
const actions = ['FTS Full', 'FTS Incremental', 'Vector Full', 'Vector Incremental'];
|
||||
for (const action of actions) {
|
||||
const button = page.getByRole('button', { name: new RegExp(action, 'i') });
|
||||
const isVisible = await button.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(button).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.6 - should display settings form', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Switch to Settings tab
|
||||
const settingsTab = page.getByRole('tab', { name: /Settings/i });
|
||||
const settingsVisible = await settingsTab.isVisible().catch(() => false);
|
||||
if (settingsVisible) {
|
||||
await settingsTab.click();
|
||||
|
||||
// Check for form inputs
|
||||
const indexDirInput = page.getByLabel(/Index Directory/i);
|
||||
const maxWorkersInput = page.getByLabel(/Max Workers/i);
|
||||
const batchSizeInput = page.getByLabel(/Batch Size/i);
|
||||
|
||||
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
|
||||
const maxWorkersVisible = await maxWorkersInput.isVisible().catch(() => false);
|
||||
const batchSizeVisible = await batchSizeInput.isVisible().catch(() => false);
|
||||
|
||||
// At least one should be visible if the form is rendered
|
||||
expect(indexDirVisible || maxWorkersVisible || batchSizeVisible).toBe(true);
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.7 - should save settings configuration', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const settingsTab = page.getByRole('tab', { name: /Settings/i });
|
||||
const settingsVisible = await settingsTab.isVisible().catch(() => false);
|
||||
if (settingsVisible) {
|
||||
await settingsTab.click();
|
||||
|
||||
// Modify index directory
|
||||
const indexDirInput = page.getByLabel(/Index Directory/i);
|
||||
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
|
||||
if (indexDirVisible) {
|
||||
await indexDirInput.fill('/custom/index/path');
|
||||
|
||||
// Click save button
|
||||
const saveButton = page.getByRole('button', { name: /Save/i });
|
||||
const saveVisible = await saveButton.isVisible().catch(() => false);
|
||||
if (saveVisible && !(await saveButton.isDisabled())) {
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for success or completion
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.8 - should validate settings form', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const settingsTab = page.getByRole('tab', { name: /Settings/i });
|
||||
const settingsVisible = await settingsTab.isVisible().catch(() => false);
|
||||
if (settingsVisible) {
|
||||
await settingsTab.click();
|
||||
|
||||
// Try to save with empty index directory
|
||||
const indexDirInput = page.getByLabel(/Index Directory/i);
|
||||
const indexDirVisible = await indexDirInput.isVisible().catch(() => false);
|
||||
if (indexDirVisible) {
|
||||
await indexDirInput.fill('');
|
||||
|
||||
const saveButton = page.getByRole('button', { name: /Save/i });
|
||||
const saveVisible = await saveButton.isVisible().catch(() => false);
|
||||
if (saveVisible && !(await saveButton.isDisabled())) {
|
||||
await saveButton.click();
|
||||
|
||||
// Check for validation error
|
||||
const errorMessage = page.getByText(/required/i, { exact: false });
|
||||
const hasError = await errorMessage.isVisible().catch(() => false);
|
||||
if (hasError) {
|
||||
await expect(errorMessage).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.9 - should display models list', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Switch to Models tab
|
||||
const modelsTab = page.getByRole('tab', { name: /Models/i });
|
||||
const modelsVisible = await modelsTab.isVisible().catch(() => false);
|
||||
if (modelsVisible) {
|
||||
await modelsTab.click();
|
||||
|
||||
// Look for filter buttons
|
||||
const filters = ['All', 'Embedding', 'Reranker', 'Downloaded', 'Available'];
|
||||
for (const filter of filters) {
|
||||
const button = page.getByRole('button', { name: new RegExp(filter, 'i') });
|
||||
const isVisible = await button.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
await expect(button).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.10 - should filter models by type', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const modelsTab = page.getByRole('tab', { name: /Models/i });
|
||||
const modelsVisible = await modelsTab.isVisible().catch(() => false);
|
||||
if (modelsVisible) {
|
||||
await modelsTab.click();
|
||||
|
||||
// Click Embedding filter
|
||||
const embeddingFilter = page.getByRole('button', { name: /Embedding/i });
|
||||
const embeddingVisible = await embeddingFilter.isVisible().catch(() => false);
|
||||
if (embeddingVisible) {
|
||||
await embeddingFilter.click();
|
||||
// Filter should be active
|
||||
await expect(embeddingFilter).toHaveAttribute('data-state', 'active');
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.11 - should search models', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const modelsTab = page.getByRole('tab', { name: /Models/i });
|
||||
const modelsVisible = await modelsTab.isVisible().catch(() => false);
|
||||
if (modelsVisible) {
|
||||
await modelsTab.click();
|
||||
|
||||
// Type in search box
|
||||
const searchInput = page.getByPlaceholderText(/Search models/i);
|
||||
const searchVisible = await searchInput.isVisible().catch(() => false);
|
||||
if (searchVisible) {
|
||||
await searchInput.fill('test-model');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.12 - should handle bootstrap when not installed', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for bootstrap button (only visible when not installed)
|
||||
const bootstrapButton = page.getByRole('button', { name: /Bootstrap/i });
|
||||
const bootstrapVisible = await bootstrapButton.isVisible().catch(() => false);
|
||||
if (bootstrapVisible) {
|
||||
await expect(bootstrapButton).toBeVisible();
|
||||
// Don't actually click it to avoid installing in test
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.13 - should show uninstall confirmation', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for uninstall button (only visible when installed)
|
||||
const uninstallButton = page.getByRole('button', { name: /Uninstall/i });
|
||||
const uninstallVisible = await uninstallButton.isVisible().catch(() => false);
|
||||
if (uninstallVisible) {
|
||||
// Set up dialog handler before clicking
|
||||
page.on('dialog', async (dialog) => {
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
await uninstallButton.click();
|
||||
|
||||
// Check for confirmation dialog
|
||||
const dialog = page.getByRole('dialog');
|
||||
const dialogVisible = await dialog.isVisible().catch(() => false);
|
||||
// Dialog may or may not appear depending on implementation
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.14 - should display refresh button', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for refresh button
|
||||
const refreshButton = page.getByRole('button', { name: /Refresh/i }).or(
|
||||
page.getByRole('button', { name: /refresh/i })
|
||||
);
|
||||
const refreshVisible = await refreshButton.isVisible().catch(() => false);
|
||||
if (refreshVisible) {
|
||||
await expect(refreshButton).toBeVisible();
|
||||
|
||||
// Click refresh
|
||||
await refreshButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.15 - should handle API errors gracefully', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
// Mock API failure for CodexLens endpoint
|
||||
await page.route('**/api/codexlens/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal Server Error' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for error indicator or graceful degradation
|
||||
const title = page.getByText(/CodexLens/i);
|
||||
const titleVisible = await title.isVisible().catch(() => false);
|
||||
|
||||
// Restore routing
|
||||
await page.unroute('**/api/codexlens/**');
|
||||
|
||||
// Page should still be visible despite error
|
||||
expect(titleVisible).toBe(true);
|
||||
|
||||
monitoring.assertClean({ ignoreAPIPatterns: ['/api/codexlens'], allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.16 - should switch language and verify translations', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Switch to Chinese if language switcher is available
|
||||
const languageSwitcher = page.getByRole('button', { name: /中文|Language/i });
|
||||
const switcherVisible = await languageSwitcher.isVisible().catch(() => false);
|
||||
if (switcherVisible) {
|
||||
await languageSwitcher.click();
|
||||
|
||||
// Check for Chinese translations
|
||||
const chineseTitle = page.getByText(/CodexLens/i);
|
||||
await expect(chineseTitle).toBeVisible();
|
||||
|
||||
// Check for Chinese tab labels
|
||||
const overviewTab = page.getByRole('tab', { name: /概览/i });
|
||||
const overviewVisible = await overviewTab.isVisible().catch(() => false);
|
||||
if (overviewVisible) {
|
||||
await expect(overviewTab).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.17 - should navigate from sidebar', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/', { waitUntil: 'networkidle' as const });
|
||||
|
||||
// Look for CodexLens link in sidebar
|
||||
const codexLensLink = page.getByRole('link', { name: /CodexLens/i });
|
||||
const linkVisible = await codexLensLink.isVisible().catch(() => false);
|
||||
if (linkVisible) {
|
||||
await codexLensLink.click();
|
||||
await page.waitForURL(/codexlens/);
|
||||
expect(page.url()).toContain('codexlens');
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
|
||||
test('L4.18 - should display empty state when no models', async ({ page }) => {
|
||||
const monitoring = setupEnhancedMonitoring(page);
|
||||
|
||||
await page.goto('/settings/codexlens', { waitUntil: 'networkidle' as const });
|
||||
|
||||
const modelsTab = page.getByRole('tab', { name: /Models/i });
|
||||
const modelsVisible = await modelsTab.isVisible().catch(() => false);
|
||||
if (modelsVisible) {
|
||||
await modelsTab.click();
|
||||
|
||||
// Search for a non-existent model to show empty state
|
||||
const searchInput = page.getByPlaceholderText(/Search models/i);
|
||||
const searchVisible = await searchInput.isVisible().catch(() => false);
|
||||
if (searchVisible) {
|
||||
await searchInput.fill('nonexistent-model-xyz-123');
|
||||
|
||||
// Look for empty state message
|
||||
const emptyState = page.getByText(/No models found/i);
|
||||
const emptyVisible = await emptyState.isVisible().catch(() => false);
|
||||
if (emptyVisible) {
|
||||
await expect(emptyState).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitoring.assertClean({ allowWarnings: true });
|
||||
monitoring.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user