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:
catlog22
2026-02-01 17:45:38 +08:00
parent 8dc115a894
commit d46406df4a
79 changed files with 11819 additions and 2455 deletions

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

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

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

View 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();
});
});
});

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

View 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();
});
});
});

View 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>
);
}

View 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)
);
});
});
});
});

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

View File

@@ -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',

View File

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

View File

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

View File

@@ -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>

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

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>
</>
);
}

View File

@@ -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>

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

View File

@@ -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) => ({

View File

@@ -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>
);
}

View File

@@ -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: '',
};
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -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>
);
}

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

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

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

View File

@@ -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,

View File

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

View File

@@ -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,
});

View 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);
});
});
});

View 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,
};
}

View File

@@ -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,
});

View File

@@ -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,
};
}

View File

@@ -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,
});

View File

@@ -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),
});
}

View File

@@ -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"

View 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"
}
}
}

View File

@@ -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'),

View File

@@ -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",

View File

@@ -16,6 +16,7 @@
"prompts": "Prompt History",
"settings": "Settings",
"mcp": "MCP Servers",
"codexlens": "CodexLens",
"endpoints": "CLI Endpoints",
"installations": "Installations",
"help": "Help",

View File

@@ -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",

View File

@@ -66,6 +66,10 @@
"automation": "自动化"
},
"templates": {
"ccw-status-tracker": {
"name": "CCW 状态追踪器",
"description": "解析 CCW status.json 并显示当前/下一个命令"
},
"ccw-notify": {
"name": "CCW 面板通知",
"description": "当文件被写入时向 CCW 面板发送通知"

View 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": "尝试调整搜索或筛选条件"
}
}
}

View File

@@ -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": "活跃",

View File

@@ -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'),

View File

@@ -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": "发现",

View File

@@ -16,6 +16,7 @@
"prompts": "提示历史",
"settings": "设置",
"mcp": "MCP 服务器",
"codexlens": "CodexLens",
"endpoints": "CLI 端点",
"installations": "安装",
"help": "帮助",

View File

@@ -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": "更新时间",

View 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();
});
});
});

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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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) */}

View File

@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage';

View 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>
);
}

View File

@@ -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>
);
}

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

View 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>
);
}

View File

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

View File

@@ -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">

View File

@@ -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',

View File

@@ -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',
},
};

View 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();
});
});