✨ feat: improve CodeReviewPanel.tsx

swadhinbiswas committed 3 months ago
d0e310a
1 parent 4fae0b9
commit d0e310a
1 file changed
+597 -0
Showing 1 changed file+597-0
▼src/components/pr/CodeReviewPanel.tsx
+597-0
1
+"use client";
2
+
3
+import { useState, useEffect, useCallback } from "react";
4
+import { motion, AnimatePresence } from "framer-motion";
5
+import {
6
+ Shield,
7
+ ShieldAlert,
8
+ AlertTriangle,
9
+ CheckCircle2,
10
+ XCircle,
11
+ Info,
12
+ ChevronDown,
13
+ ChevronRight,
14
+ Layers,
15
+ Database,
16
+ Server,
17
+ Monitor,
18
+ Lock,
19
+ TestTube,
20
+ FileText,
21
+ Zap,
22
+ RefreshCw,
23
+ MessageSquare,
24
+ TrendingUp,
25
+ Minus,
26
+ Eye,
27
+} from "lucide-react";
28
+
29
+interface AnalysisData {
30
+ id: string;
31
+ status: string;
32
+ healthScore: number | null;
33
+ healthGrade: string | null;
34
+ blastRadius: {
35
+ directChanges: number;
36
+ transitiveChanges: number;
37
+ affectedRoutes: number;
38
+ affectedComponents: number;
39
+ affectedLayers: string[];
40
+ riskScore: number;
41
+ riskLevel: string;
42
+ summary: string;
43
+ } | null;
44
+ architectureImpact: {
45
+ layers: Array<{
46
+ layer: string;
47
+ files: string[];
48
+ changeCount: number;
49
+ severity: string;
50
+ }>;
51
+ crossLayerChanges: boolean;
52
+ affectedLayers: string[];
53
+ riskLevel: string;
54
+ riskSummary: string;
55
+ securityImpact: boolean;
56
+ databaseImpact: boolean;
57
+ } | null;
58
+ changeGroups: Array<{
59
+ id: string;
60
+ title: string;
61
+ description: string;
62
+ files: string[];
63
+ layer: string;
64
+ riskLevel: string;
65
+ totalAdditions: number;
66
+ totalDeletions: number;
67
+ }> | null;
68
+ complexityData: {
69
+ cyclomaticComplexity: number;
70
+ nestingDepth: number;
71
+ longFunctions: Array<{ name: string; lines: number }>;
72
+ riskLevel: string;
73
+ } | null;
74
+ summaries: any;
75
+ filesAnalyzed: number;
76
+ totalAdditions: number;
77
+ totalDeletions: number;
78
+ model: string | null;
79
+ provider: string | null;
80
+ errorMessage: string | null;
81
+ createdAt: string;
82
+ completedAt: string | null;
83
+}
84
+
85
+interface InlineComment {
86
+ id: string;
87
+ filePath: string;
88
+ line: number;
89
+ severity: string;
90
+ type: string;
91
+ category: string;
92
+ title: string;
93
+ message: string;
94
+ suggestedFix: string | null;
95
+ explanation: string | null;
96
+ confidence: number | null;
97
+ isResolved: number | null;
98
+ isApplied: number | null;
99
+}
100
+
101
+interface Props {
102
+ owner: string;
103
+ repo: string;
104
+ prNumber: number;
105
+}
106
+
107
+const LAYER_ICONS: Record<string, React.ComponentType<any>> = {
108
+ database: Database,
109
+ backend: Server,
110
+ frontend: Monitor,
111
+ infrastructure: Layers,
112
+ security: Lock,
113
+ testing: TestTube,
114
+ documentation: FileText,
115
+ config: FileText,
116
+};
117
+
118
+function getSeverityConfig(severity: string) {
119
+ switch (severity) {
120
+ case "critical":
121
+ case "error":
122
+ return { color: "text-red-500", bg: "bg-red-500/10", icon: XCircle };
123
+ case "warning":
124
+ case "medium":
125
+ return { color: "text-yellow-500", bg: "bg-yellow-500/10", icon: AlertTriangle };
126
+ case "info":
127
+ return { color: "text-blue-500", bg: "bg-blue-500/10", icon: Info };
128
+ case "low":
129
+ return { color: "text-green-500", bg: "bg-green-500/10", icon: CheckCircle2 };
130
+ default:
131
+ return { color: "text-muted-foreground", bg: "bg-muted", icon: Info };
132
+ }
133
+}
134
+
135
+function getGradeColor(grade: string | null) {
136
+ switch (grade) {
137
+ case "A+":
138
+ case "A":
139
+ return "text-green-500";
140
+ case "B":
141
+ return "text-blue-500";
142
+ case "C":
143
+ return "text-yellow-500";
144
+ case "D":
145
+ return "text-orange-500";
146
+ case "F":
147
+ return "text-red-500";
148
+ default:
149
+ return "text-muted-foreground";
150
+ }
151
+}
152
+
153
+function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
154
+ return (
155
+ <button
156
+ onClick={onClick}
157
+ className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
158
+ active
159
+ ? "bg-primary text-primary-foreground"
160
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
161
+ }`}
162
+ >
163
+ {children}
164
+ </button>
165
+ );
166
+}
167
+
168
+export default function CodeReviewPanel({ owner, repo, prNumber }: Props) {
169
+ const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
170
+ const [comments, setComments] = useState<InlineComment[]>([]);
171
+ const [loading, setLoading] = useState(true);
172
+ const [analyzing, setAnalyzing] = useState(false);
173
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
174
+ const [activeTab, setActiveTab] = useState<"overview" | "groups" | "comments">("overview");
175
+
176
+ const fetchAnalysis = useCallback(async () => {
177
+ try {
178
+ const res = await fetch(`/api/repos/${owner}/${repo}/pulls/${prNumber}/analysis`);
179
+ const data = await res.json();
180
+ if (data.data) {
181
+ setAnalysis(data.data.analysis);
182
+ setComments(data.data.inlineComments || []);
183
+ }
184
+ } catch (err) {
185
+ console.error("Failed to fetch analysis:", err);
186
+ } finally {
187
+ setLoading(false);
188
+ }
189
+ }, [owner, repo, prNumber]);
190
+
191
+ const triggerAnalysis = useCallback(async () => {
192
+ setAnalyzing(true);
193
+ try {
194
+ const res = await fetch(`/api/repos/${owner}/${repo}/pulls/${prNumber}/analysis`, {
195
+ method: "POST",
196
+ });
197
+ const data = await res.json();
198
+ if (data.data?.status === "running") {
199
+ const pollInterval = setInterval(async () => {
200
+ const pollRes = await fetch(`/api/repos/${owner}/${repo}/pulls/${prNumber}/analysis`);
201
+ const pollData = await pollRes.json();
202
+ if (pollData.data?.analysis?.status === "completed" || pollData.data?.analysis?.status === "failed") {
203
+ clearInterval(pollInterval);
204
+ await fetchAnalysis();
205
+ setAnalyzing(false);
206
+ }
207
+ }, 3000);
208
+ }
209
+ } catch (err) {
210
+ console.error("Failed to trigger analysis:", err);
211
+ setAnalyzing(false);
212
+ }
213
+ }, [owner, repo, prNumber, fetchAnalysis]);
214
+
215
+ useEffect(() => {
216
+ fetchAnalysis();
217
+ }, [fetchAnalysis]);
218
+
219
+ useEffect(() => {
220
+ if (analysis?.status === "running") {
221
+ const interval = setInterval(fetchAnalysis, 5000);
222
+ return () => clearInterval(interval);
223
+ }
224
+ }, [analysis?.status, fetchAnalysis]);
225
+
226
+ const toggleGroup = (id: string) => {
227
+ setExpandedGroups(prev => {
228
+ const next = new Set(prev);
229
+ if (next.has(id)) next.delete(id);
230
+ else next.add(id);
231
+ return next;
232
+ });
233
+ };
234
+
235
+ if (loading) {
236
+ return (
237
+ <div className="border border-border bg-card rounded-lg p-6">
238
+ <div className="flex items-center gap-3">
239
+ <RefreshCw className="h-5 w-5 text-muted-foreground animate-spin" />
240
+ <span className="text-sm text-muted-foreground">Loading analysis...</span>
241
+ </div>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ if (!analysis) {
247
+ return (
248
+ <div className="border border-border bg-card rounded-lg p-6">
249
+ <div className="text-center">
250
+ <Shield className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
251
+ <h3 className="text-sm font-semibold text-foreground mb-1">No Analysis Yet</h3>
252
+ <p className="text-xs text-muted-foreground mb-4">
253
+ Run an AI-powered analysis to get code review insights.
254
+ </p>
255
+ <button
256
+ onClick={triggerAnalysis}
257
+ disabled={analyzing}
258
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all"
259
+ >
260
+ {analyzing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Shield className="h-4 w-4" />}
261
+ {analyzing ? "Analyzing..." : "Run Analysis"}
262
+ </button>
263
+ </div>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ const gradeColor = getGradeColor(analysis.healthGrade);
269
+ const riskConfig = getSeverityConfig(analysis.blastRadius?.riskLevel || "low");
270
+ const RiskIcon = riskConfig.icon;
271
+
272
+ return (
273
+ <div className="space-y-4">
274
+ {/* Health Score Header */}
275
+ <div className="border border-border bg-card rounded-lg p-4">
276
+ <div className="flex items-center justify-between mb-3">
277
+ <div className="flex items-center gap-3">
278
+ <div className="relative">
279
+ <div className={`w-14 h-14 rounded-full border-4 border-current flex items-center justify-center ${gradeColor}`}>
280
+ <span className="text-lg font-bold">{analysis.healthScore || "—"}</span>
281
+ </div>
282
+ <div className="absolute -bottom-1 -right-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-card border border-border">
283
+ {analysis.healthGrade || "—"}
284
+ </div>
285
+ </div>
286
+ <div>
287
+ <h3 className="text-sm font-semibold text-foreground">PR Health Score</h3>
288
+ <p className="text-xs text-muted-foreground">
289
+ {analysis.filesAnalyzed} file(s) analyzed • +{analysis.totalAdditions}/-{analysis.totalDeletions}
290
+ </p>
291
+ </div>
292
+ </div>
293
+ <button
294
+ onClick={triggerAnalysis}
295
+ disabled={analyzing}
296
+ className="p-2 rounded-lg border border-border hover:bg-accent disabled:opacity-50 transition-all"
297
+ title="Re-run analysis"
298
+ >
299
+ <RefreshCw className={`h-4 w-4 text-muted-foreground ${analyzing ? "animate-spin" : ""}`} />
300
+ </button>
301
+ </div>
302
+
303
+ {/* Risk Badge */}
304
+ <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${riskConfig.bg} ${riskConfig.color}`}>
305
+ <RiskIcon className="h-3.5 w-3.5" />
306
+ {analysis.blastRadius?.riskLevel?.charAt(0).toUpperCase()}{analysis.blastRadius?.riskLevel?.slice(1)} Risk
307
+ </div>
308
+
309
+ {/* Blast Radius */}
310
+ {analysis.blastRadius && (
311
+ <div className="mt-3 grid grid-cols-3 gap-2">
312
+ <div className="text-center p-2 rounded-lg bg-muted/50">
313
+ <div className="text-lg font-bold text-foreground">{analysis.blastRadius.directChanges}</div>
314
+ <div className="text-[10px] text-muted-foreground">Files Changed</div>
315
+ </div>
316
+ <div className="text-center p-2 rounded-lg bg-muted/50">
317
+ <div className="text-lg font-bold text-foreground">{analysis.blastRadius.affectedRoutes}</div>
318
+ <div className="text-[10px] text-muted-foreground">Routes Affected</div>
319
+ </div>
320
+ <div className="text-center p-2 rounded-lg bg-muted/50">
321
+ <div className="text-lg font-bold text-foreground">{analysis.blastRadius.affectedComponents}</div>
322
+ <div className="text-[10px] text-muted-foreground">Components</div>
323
+ </div>
324
+ </div>
325
+ )}
326
+
327
+ {analysis.blastRadius?.summary && (
328
+ <p className="mt-2 text-xs text-muted-foreground">{analysis.blastRadius.summary}</p>
329
+ )}
330
+ </div>
331
+
332
+ {/* Tab Navigation */}
333
+ <div className="flex gap-1 border border-border bg-card rounded-lg p-1">
334
+ <TabButton active={activeTab === "overview"} onClick={() => setActiveTab("overview")}>
335
+ <Eye className="h-3.5 w-3.5 inline mr-1" />
336
+ Overview
337
+ </TabButton>
338
+ <TabButton active={activeTab === "groups"} onClick={() => setActiveTab("groups")}>
339
+ <Layers className="h-3.5 w-3.5 inline mr-1" />
340
+ Groups
341
+ </TabButton>
342
+ <TabButton active={activeTab === "comments"} onClick={() => setActiveTab("comments")}>
343
+ <MessageSquare className="h-3.5 w-3.5 inline mr-1" />
344
+ Comments
345
+ {comments.length > 0 && (
346
+ <span className="ml-1 px-1.5 py-0.5 rounded-full bg-primary/20 text-primary text-[10px]">
347
+ {comments.length}
348
+ </span>
349
+ )}
350
+ </TabButton>
351
+ </div>
352
+
353
+ {/* Overview Tab */}
354
+ {activeTab === "overview" && (
355
+ <div className="space-y-4">
356
+ {/* Architecture Impact */}
357
+ {analysis.architectureImpact && (
358
+ <div className="border border-border bg-card rounded-lg p-4">
359
+ <h4 className="text-xs font-semibold text-foreground mb-3 flex items-center gap-2">
360
+ <Layers className="h-4 w-4" />
361
+ Architecture Impact
362
+ </h4>
363
+ <div className="space-y-2">
364
+ {analysis.architectureImpact.layers.map((layer, i) => {
365
+ const LayerIcon = LAYER_ICONS[layer.layer] || Layers;
366
+ const sevColor = layer.severity === "high" ? "bg-red-500/10 text-red-500"
367
+ : layer.severity === "medium" ? "bg-yellow-500/10 text-yellow-500"
368
+ : "bg-green-500/10 text-green-500";
369
+ return (
370
+ <div key={i} className="flex items-center justify-between p-2 rounded-lg bg-muted/50">
371
+ <div className="flex items-center gap-2">
372
+ <LayerIcon className="h-4 w-4 text-muted-foreground" />
373
+ <span className="text-xs font-medium text-foreground capitalize">{layer.layer}</span>
374
+ </div>
375
+ <div className="flex items-center gap-2">
376
+ <span className="text-xs text-muted-foreground">{layer.changeCount} file(s)</span>
377
+ <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${sevColor}`}>
378
+ {layer.severity}
379
+ </span>
380
+ </div>
381
+ </div>
382
+ );
383
+ })}
384
+ </div>
385
+ {analysis.architectureImpact.crossLayerChanges && (
386
+ <div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-yellow-500/10 text-yellow-500 text-xs">
387
+ <AlertTriangle className="h-4 w-4" />
388
+ Changes span multiple architectural layers — review carefully
389
+ </div>
390
+ )}
391
+ {analysis.architectureImpact.securityImpact && (
392
+ <div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-500 text-xs">
393
+ <ShieldAlert className="h-4 w-4" />
394
+ Security-related files modified
395
+ </div>
396
+ )}
397
+ </div>
398
+ )}
399
+
400
+ {/* Complexity */}
401
+ {analysis.complexityData && (
402
+ <div className="border border-border bg-card rounded-lg p-4">
403
+ <h4 className="text-xs font-semibold text-foreground mb-3 flex items-center gap-2">
404
+ <TrendingUp className="h-4 w-4" />
405
+ Complexity Analysis
406
+ </h4>
407
+ <div className="grid grid-cols-2 gap-2">
408
+ <div className="p-2 rounded-lg bg-muted/50">
409
+ <div className="text-lg font-bold text-foreground">{analysis.complexityData.cyclomaticComplexity}</div>
410
+ <div className="text-[10px] text-muted-foreground">Cyclomatic Complexity</div>
411
+ </div>
412
+ <div className="p-2 rounded-lg bg-muted/50">
413
+ <div className="text-lg font-bold text-foreground">{analysis.complexityData.nestingDepth}</div>
414
+ <div className="text-[10px] text-muted-foreground">Max Nesting Depth</div>
415
+ </div>
416
+ </div>
417
+ {analysis.complexityData.longFunctions.length > 0 && (
418
+ <div className="mt-3 space-y-1">
419
+ <p className="text-[10px] text-muted-foreground font-medium">Long Functions:</p>
420
+ {analysis.complexityData.longFunctions.map((fn, i) => (
421
+ <div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
422
+ <Minus className="h-3 w-3" />
423
+ <span className="font-mono">{fn.name}</span>
424
+ <span className="text-yellow-500">({fn.lines} lines)</span>
425
+ </div>
426
+ ))}
427
+ </div>
428
+ )}
429
+ </div>
430
+ )}
431
+
432
+ {/* AI Summary */}
433
+ {analysis.summaries?.overall && (
434
+ <div className="border border-border bg-card rounded-lg p-4">
435
+ <h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-2">
436
+ <FileText className="h-4 w-4" />
437
+ AI Summary
438
+ </h4>
439
+ <p className="text-sm text-foreground leading-relaxed">
440
+ {analysis.summaries.overall.summary}
441
+ </p>
442
+ {analysis.summaries.overall.keyChanges?.length > 0 && (
443
+ <ul className="mt-3 space-y-1">
444
+ {analysis.summaries.overall.keyChanges.map((change: string, i: number) => (
445
+ <li key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
446
+ <CheckCircle2 className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-green-500" />
447
+ {change}
448
+ </li>
449
+ ))}
450
+ </ul>
451
+ )}
452
+ </div>
453
+ )}
454
+
455
+ {/* AI Provider Info */}
456
+ {analysis.provider && (
457
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground px-1">
458
+ <Zap className="h-3 w-3" />
459
+ Analyzed by {analysis.provider}{analysis.model ? ` (${analysis.model})` : ""}
460
+ </div>
461
+ )}
462
+ </div>
463
+ )}
464
+
465
+ {/* Groups Tab */}
466
+ {activeTab === "groups" && (
467
+ <div className="space-y-2">
468
+ {analysis.changeGroups && analysis.changeGroups.length > 0 ? (
469
+ analysis.changeGroups.map(group => {
470
+ const isExpanded = expandedGroups.has(group.id);
471
+ const groupRisk = getSeverityConfig(group.riskLevel);
472
+ const GroupRiskIcon = groupRisk.icon;
473
+ const LayerIcon = LAYER_ICONS[group.layer] || Layers;
474
+
475
+ return (
476
+ <div key={group.id} className="border border-border bg-card rounded-lg overflow-hidden">
477
+ <button
478
+ onClick={() => toggleGroup(group.id)}
479
+ className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors"
480
+ >
481
+ <div className="flex items-center gap-3">
482
+ {isExpanded ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
483
+ <LayerIcon className="h-4 w-4 text-muted-foreground" />
484
+ <div className="text-left">
485
+ <div className="text-sm font-medium text-foreground">{group.title}</div>
486
+ <div className="text-[10px] text-muted-foreground">
487
+ {group.files.length} file(s) • +{group.totalAdditions}/-{group.totalDeletions}
488
+ </div>
489
+ </div>
490
+ </div>
491
+ <div className="flex items-center gap-2">
492
+ <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${groupRisk.bg} ${groupRisk.color}`}>
493
+ {group.riskLevel}
494
+ </span>
495
+ </div>
496
+ </button>
497
+
498
+ <AnimatePresence>
499
+ {isExpanded && (
500
+ <motion.div
501
+ initial={{ height: 0 }}
502
+ animate={{ height: "auto" }}
503
+ exit={{ height: 0 }}
504
+ className="overflow-hidden"
505
+ >
506
+ <div className="border-t border-border p-3 space-y-1">
507
+ {group.files.map(file => (
508
+ <div key={file} className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
509
+ <FileText className="h-3 w-3 flex-shrink-0" />
510
+ <span className="truncate">{file}</span>
511
+ </div>
512
+ ))}
513
+ </div>
514
+ </motion.div>
515
+ )}
516
+ </AnimatePresence>
517
+ </div>
518
+ );
519
+ })
520
+ ) : (
521
+ <div className="border border-dashed border-border rounded-lg py-8 text-center">
522
+ <Layers className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
523
+ <p className="text-xs text-muted-foreground">No change groups available</p>
524
+ </div>
525
+ )}
526
+ </div>
527
+ )}
528
+
529
+ {/* Comments Tab */}
530
+ {activeTab === "comments" && (
531
+ <div className="space-y-2">
532
+ {comments.length > 0 ? (
533
+ comments.map(comment => {
534
+ const cfg = getSeverityConfig(comment.severity);
535
+ const CommentIcon = cfg.icon;
536
+ return (
537
+ <div key={comment.id} className="border border-border bg-card rounded-lg p-3">
538
+ <div className="flex items-start justify-between gap-2 mb-2">
539
+ <div className="flex items-center gap-2">
540
+ <CommentIcon className={`h-4 w-4 ${cfg.color}`} />
541
+ <span className="text-sm font-medium text-foreground">{comment.title}</span>
542
+ </div>
543
+ <span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${cfg.bg} ${cfg.color}`}>
544
+ {comment.severity}
545
+ </span>
546
+ </div>
547
+ <p className="text-xs text-foreground leading-relaxed mb-2">{comment.message}</p>
548
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
549
+ <span className="font-mono">{comment.filePath}:{comment.line}</span>
550
+ <span>•</span>
551
+ <span className="capitalize">{comment.type}</span>
552
+ {comment.confidence != null && (
553
+ <>
554
+ <span>•</span>
555
+ <span>{comment.confidence}% confidence</span>
556
+ </>
557
+ )}
558
+ </div>
559
+ {comment.suggestedFix && (
560
+ <div className="mt-2 p-2 rounded bg-muted/50 text-xs font-mono text-foreground overflow-x-auto">
561
+ {comment.suggestedFix}
562
+ </div>
563
+ )}
564
+ </div>
565
+ );
566
+ })
567
+ ) : (
568
+ <div className="border border-dashed border-border rounded-lg py-8 text-center">
569
+ <MessageSquare className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
570
+ <p className="text-xs text-muted-foreground">No inline comments yet</p>
571
+ </div>
572
+ )}
573
+ </div>
574
+ )}
575
+
576
+ {/* Error State */}
577
+ {analysis.status === "failed" && analysis.errorMessage && (
578
+ <div className="border border-red-500/30 bg-red-500/5 rounded-lg p-4">
579
+ <div className="flex items-center gap-2 text-sm text-red-500">
580
+ <XCircle className="h-4 w-4" />
581
+ Analysis failed: {analysis.errorMessage}
582
+ </div>
583
+ </div>
584
+ )}
585
+
586
+ {/* Running State */}
587
+ {analysis.status === "running" && (
588
+ <div className="border border-primary/30 bg-primary/5 rounded-lg p-4">
589
+ <div className="flex items-center gap-2 text-sm text-primary">
590
+ <RefreshCw className="h-4 w-4 animate-spin" />
591
+ Analysis in progress...
592
+ </div>
593
+ </div>
594
+ )}
595
+ </div>
596
+ );
597
+}
0
598