feat(discovery): add FindingDrawer component and restructure i18n keys

- Add FindingDrawer component for displaying finding details when no
  associated issue exists
- Refactor i18n keys for better organization:
  - status.* → session.status.* (session-related)
  - severity.* → findings.severity.* (finding-related)
- Update DiscoveryDetail to show FindingDrawer for orphan findings
- Add severity/priority mapping in discovery-routes for compatibility
This commit is contained in:
catlog22
2026-02-28 16:26:11 +08:00
parent c3ddf7e322
commit cd54c10256
10 changed files with 291 additions and 25 deletions

View File

@@ -21,17 +21,17 @@ const statusConfig = {
running: { running: {
icon: Clock, icon: Clock,
variant: 'warning' as const, variant: 'warning' as const,
label: 'issues.discovery.status.running', label: 'issues.discovery.session.status.running',
}, },
completed: { completed: {
icon: CheckCircle, icon: CheckCircle,
variant: 'success' as const, variant: 'success' as const,
label: 'issues.discovery.status.completed', label: 'issues.discovery.session.status.completed',
}, },
failed: { failed: {
icon: XCircle, icon: XCircle,
variant: 'destructive' as const, variant: 'destructive' as const,
label: 'issues.discovery.status.failed', label: 'issues.discovery.session.status.failed',
}, },
}; };
@@ -79,7 +79,7 @@ export function DiscoveryCard({ session, isActive, onClick }: DiscoveryCardProps
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.findings' })}:</span> <span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.session.findings' }, { count: session.findings_count })}:</span>
<span className="font-medium text-foreground">{session.findings_count}</span> <span className="font-medium text-foreground">{session.findings_count}</span>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress'; import { Progress } from '@/components/ui/Progress';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer'; import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { FindingDrawer } from './FindingDrawer';
import type { DiscoverySession, Finding } from '@/lib/api'; import type { DiscoverySession, Finding } from '@/lib/api';
import type { Issue } from '@/lib/api'; import type { Issue } from '@/lib/api';
import type { FindingFilters } from '@/hooks/useIssues'; import type { FindingFilters } from '@/hooks/useIssues';
@@ -43,6 +44,7 @@ export function DiscoveryDetail({
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('findings'); const [activeTab, setActiveTab] = useState('findings');
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null); const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [selectedFinding, setSelectedFinding] = useState<Finding | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const handleFindingClick = (finding: Finding) => { const handleFindingClick = (finding: Finding) => {
@@ -51,14 +53,21 @@ export function DiscoveryDetail({
const relatedIssue = issues.find(i => i.id === finding.issue_id); const relatedIssue = issues.find(i => i.id === finding.issue_id);
if (relatedIssue) { if (relatedIssue) {
setSelectedIssue(relatedIssue); setSelectedIssue(relatedIssue);
return;
} }
} }
// Otherwise, show the finding details in FindingDrawer
setSelectedFinding(finding);
}; };
const handleCloseDrawer = () => { const handleCloseIssueDrawer = () => {
setSelectedIssue(null); setSelectedIssue(null);
}; };
const handleCloseFindingDrawer = () => {
setSelectedFinding(null);
};
const handleExportSelected = async () => { const handleExportSelected = async () => {
if (onExportSelected && selectedIds.length > 0) { if (onExportSelected && selectedIds.length > 0) {
await onExportSelected(selectedIds); await onExportSelected(selectedIds);
@@ -130,7 +139,7 @@ export function DiscoveryDetail({
<Badge <Badge
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'} variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
> >
{formatMessage({ id: `issues.discovery.status.${session.status}` })} {formatMessage({ id: `issues.discovery.session.status.${session.status}` })}
</Badge> </Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatMessage({ id: 'issues.discovery.createdAt' })}: {formatDate(session.created_at)} {formatMessage({ id: 'issues.discovery.createdAt' })}: {formatDate(session.created_at)}
@@ -192,7 +201,7 @@ export function DiscoveryDetail({
<Badge <Badge
variant={severity === 'critical' || severity === 'high' ? 'destructive' : severity === 'medium' ? 'warning' : 'secondary'} variant={severity === 'critical' || severity === 'high' ? 'destructive' : severity === 'medium' ? 'warning' : 'secondary'}
> >
{formatMessage({ id: `issues.discovery.severity.${severity}` })} {formatMessage({ id: `issues.discovery.findings.severity.${severity}` })}
</Badge> </Badge>
<span className="font-medium">{count}</span> <span className="font-medium">{count}</span>
</div> </div>
@@ -240,7 +249,7 @@ export function DiscoveryDetail({
<Badge <Badge
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'} variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
> >
{formatMessage({ id: `issues.discovery.status.${session.status}` })} {formatMessage({ id: `issues.discovery.session.status.${session.status}` })}
</Badge> </Badge>
</div> </div>
<div> <div>
@@ -277,7 +286,14 @@ export function DiscoveryDetail({
<IssueDrawer <IssueDrawer
issue={selectedIssue} issue={selectedIssue}
isOpen={selectedIssue !== null} isOpen={selectedIssue !== null}
onClose={handleCloseDrawer} onClose={handleCloseIssueDrawer}
/>
{/* Finding Detail Drawer */}
<FindingDrawer
finding={selectedFinding}
isOpen={selectedFinding !== null}
onClose={handleCloseFindingDrawer}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,213 @@
// ========================================
// FindingDrawer Component
// ========================================
// Right-side finding detail drawer for displaying discovery finding details
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, AlertTriangle, ExternalLink, MapPin, Code, Lightbulb, Target } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import type { Finding } from '@/lib/api';
// ========== Types ==========
export interface FindingDrawerProps {
finding: Finding | null;
isOpen: boolean;
onClose: () => void;
}
// ========== Severity Configuration ==========
const severityConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' }> = {
critical: { label: 'issues.discovery.findings.severity.critical', variant: 'destructive' },
high: { label: 'issues.discovery.findings.severity.high', variant: 'destructive' },
medium: { label: 'issues.discovery.findings.severity.medium', variant: 'warning' },
low: { label: 'issues.discovery.findings.severity.low', variant: 'secondary' },
};
function getSeverityConfig(severity: string) {
return severityConfig[severity] || { label: 'issues.discovery.findings.severity.unknown', variant: 'outline' };
}
// ========== Component ==========
export function FindingDrawer({ finding, isOpen, onClose }: FindingDrawerProps) {
const { formatMessage } = useIntl();
// ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!finding || !isOpen) {
return null;
}
const severity = getSeverityConfig(finding.severity);
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">{finding.id}</span>
<Badge variant={severity.variant}>
{formatMessage({ id: severity.label })}
</Badge>
{finding.type && (
<Badge variant="outline">{finding.type}</Badge>
)}
{finding.category && (
<Badge variant="info">{finding.category}</Badge>
)}
</div>
<h2 className="text-lg font-semibold text-foreground">
{finding.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>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.description' })}
</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{finding.description}
</p>
</div>
{/* File Location */}
{finding.file && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<MapPin className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.location' })}
</h3>
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" />
<code className="px-2 py-1 bg-muted rounded text-xs">
{finding.file}
{finding.line && `:${finding.line}`}
</code>
</div>
</div>
)}
{/* Code Snippet */}
{finding.code_snippet && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Code className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.codeSnippet' })}
</h3>
<pre className="p-3 bg-muted rounded-md overflow-x-auto text-xs border border-border">
<code>{finding.code_snippet}</code>
</pre>
</div>
)}
{/* Suggested Fix */}
{finding.suggested_issue && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.suggestedFix' })}
</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{finding.suggested_issue}
</p>
</div>
)}
{/* Confidence */}
{finding.confidence !== undefined && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Target className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.confidence' })}
</h3>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all",
finding.confidence >= 0.9 ? "bg-green-500" :
finding.confidence >= 0.7 ? "bg-yellow-500" : "bg-red-500"
)}
style={{ width: `${finding.confidence * 100}%` }}
/>
</div>
<span className="text-sm font-medium">
{Math.round(finding.confidence * 100)}%
</span>
</div>
</div>
)}
{/* Reference */}
{finding.reference && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<ExternalLink className="h-4 w-4" />
{formatMessage({ id: 'issues.discovery.findings.reference' })}
</h3>
<a
href={finding.reference}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline break-all"
>
{finding.reference}
</a>
</div>
)}
{/* Perspective */}
{finding.perspective && (
<div className="pt-4 border-t border-border">
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'issues.discovery.findings.perspective' })}: {finding.perspective}
</Badge>
</div>
)}
</div>
</div>
</>
);
}
export default FindingDrawer;

View File

@@ -24,14 +24,14 @@ interface FindingListProps {
} }
const severityConfig: Record<string, { variant: 'destructive' | 'warning' | 'secondary' | 'outline' | 'success' | 'info' | 'default'; label: string }> = { const severityConfig: Record<string, { variant: 'destructive' | 'warning' | 'secondary' | 'outline' | 'success' | 'info' | 'default'; label: string }> = {
critical: { variant: 'destructive', label: 'issues.discovery.severity.critical' }, critical: { variant: 'destructive', label: 'issues.discovery.findings.severity.critical' },
high: { variant: 'destructive', label: 'issues.discovery.severity.high' }, high: { variant: 'destructive', label: 'issues.discovery.findings.severity.high' },
medium: { variant: 'warning', label: 'issues.discovery.severity.medium' }, medium: { variant: 'warning', label: 'issues.discovery.findings.severity.medium' },
low: { variant: 'secondary', label: 'issues.discovery.severity.low' }, low: { variant: 'secondary', label: 'issues.discovery.findings.severity.low' },
}; };
function getSeverityConfig(severity: string) { function getSeverityConfig(severity: string) {
return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.severity.unknown' }; return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.findings.severity.unknown' };
} }
export function FindingList({ export function FindingList({
@@ -116,10 +116,10 @@ export function FindingList({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.severity.all' })}</SelectItem> <SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.severity.all' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem> <SelectItem value="critical">{formatMessage({ id: 'issues.discovery.findings.severity.critical' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem> <SelectItem value="high">{formatMessage({ id: 'issues.discovery.findings.severity.high' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem> <SelectItem value="medium">{formatMessage({ id: 'issues.discovery.findings.severity.medium' })}</SelectItem>
<SelectItem value="low">{formatMessage({ id: 'issues.discovery.severity.low' })}</SelectItem> <SelectItem value="low">{formatMessage({ id: 'issues.discovery.findings.severity.low' })}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{uniqueTypes.length > 0 && ( {uniqueTypes.length > 0 && (

View File

@@ -1077,6 +1077,12 @@ export interface Finding {
created_at: string; created_at: string;
issue_id?: string; // Associated issue ID if exported issue_id?: string; // Associated issue ID if exported
exported?: boolean; // Whether this finding has been exported as an issue exported?: boolean; // Whether this finding has been exported as an issue
// Additional fields from discovery backend
category?: string;
suggested_issue?: string;
confidence?: number;
reference?: string;
perspective?: string;
} }
export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> { export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> {
@@ -1131,7 +1137,11 @@ export async function fetchDiscoveryFindings(
? `/api/discoveries/${encodeURIComponent(sessionId)}/findings?path=${encodeURIComponent(projectPath)}` ? `/api/discoveries/${encodeURIComponent(sessionId)}/findings?path=${encodeURIComponent(projectPath)}`
: `/api/discoveries/${encodeURIComponent(sessionId)}/findings`; : `/api/discoveries/${encodeURIComponent(sessionId)}/findings`;
const data = await fetchApi<{ findings?: Finding[] }>(url); const data = await fetchApi<{ findings?: Finding[] }>(url);
return data.findings ?? []; // Map backend 'priority' to frontend 'severity' for compatibility
return (data.findings ?? []).map(f => ({
...f,
severity: f.severity || (f as any).priority || 'medium'
}));
} }
/** /**

View File

@@ -353,7 +353,14 @@
"hasIssue": "Linked", "hasIssue": "Linked",
"export": "Export", "export": "Export",
"selectAll": "Select All", "selectAll": "Select All",
"deselectAll": "Deselect All" "deselectAll": "Deselect All",
"description": "Description",
"location": "Location",
"codeSnippet": "Code Snippet",
"suggestedFix": "Suggested Fix",
"confidence": "Confidence",
"reference": "Reference",
"perspective": "Perspective"
}, },
"tabs": { "tabs": {
"findings": "Findings", "findings": "Findings",

View File

@@ -353,7 +353,14 @@
"hasIssue": "已关联", "hasIssue": "已关联",
"export": "导出", "export": "导出",
"selectAll": "全选", "selectAll": "全选",
"deselectAll": "取消全选" "deselectAll": "取消全选",
"description": "问题描述",
"location": "文件位置",
"codeSnippet": "代码片段",
"suggestedFix": "建议修复",
"confidence": "置信度",
"reference": "参考链接",
"perspective": "视角"
}, },
"tabs": { "tabs": {
"findings": "发现", "findings": "发现",

View File

@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
import { Radar, AlertCircle, Loader2 } from 'lucide-react'; import { Radar, AlertCircle, Loader2 } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; 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 { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard';
import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail'; import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
@@ -29,6 +29,12 @@ export function DiscoveryPage() {
isExporting, isExporting,
} = useIssueDiscovery({ refetchInterval: 3000 }); } = 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) { if (error) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -167,6 +173,7 @@ export function DiscoveryPage() {
onExport={exportFindings} onExport={exportFindings}
onExportSelected={exportSelectedFindings} onExportSelected={exportSelectedFindings}
isExporting={isExporting} isExporting={isExporting}
issues={issues}
/> />
)} )}
</div> </div>

View File

@@ -262,7 +262,13 @@ function flattenFindings(perspectiveResults: any[]): any[] {
const allFindings: any[] = []; const allFindings: any[] = [];
for (const result of perspectiveResults) { for (const result of perspectiveResults) {
if (result.findings) { if (result.findings) {
allFindings.push(...result.findings); // Map backend 'priority' to frontend 'severity' for compatibility
const mappedFindings = result.findings.map((f: any) => ({
...f,
severity: f.severity || f.priority || 'medium',
sessionId: f.discovery_id || result.discovery_id
}));
allFindings.push(...mappedFindings);
} }
} }
return allFindings; return allFindings;

View File

@@ -105,8 +105,8 @@ export const schema: ToolSchema = {
name: 'write_file', name: 'write_file',
description: `Write content to file. Auto-creates parent directories. description: `Write content to file. Auto-creates parent directories.
Usage: write_file(path="file.js", content="code here") Required: path (string), content (string)
Options: backup=true (backup before overwrite), createDirectories=false (disable auto-creation), encoding="utf8"`, Options: backup=true, createDirectories=false, encoding="utf8"`,
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {