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

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