Files
Codewalkers/apps/web/src/components/review/ReviewHeader.tsx
Lukas May a3ee581629 fix: Merge confirmation dropdown hidden behind sticky file headers
Remove overflow-hidden from ReviewTab outer wrapper (was clipping the
absolutely-positioned dropdown), make ReviewHeader sticky with z-20 to
sit above file headers (z-10), and bump the dropdown to z-30.
2026-03-05 20:59:30 +01:00

351 lines
11 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import {
Check,
X,
GitBranch,
FileCode,
Plus,
Minus,
ExternalLink,
Loader2,
Square,
CircleDot,
RotateCcw,
ArrowRight,
Eye,
AlertCircle,
GitMerge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, ReviewStatus } from "./types";
interface PhaseOption {
id: string;
name: string;
}
interface PreviewState {
status: "idle" | "building" | "running" | "failed";
url?: string;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}
interface ReviewHeaderProps {
phases: PhaseOption[];
activePhaseId: string | null;
onPhaseSelect: (id: string) => void;
phaseName: string;
sourceBranch: string;
targetBranch: string;
files: FileDiff[];
status: ReviewStatus;
unresolvedCount: number;
onApprove: () => void;
onRequestChanges: () => void;
isRequestingChanges?: boolean;
preview: PreviewState | null;
viewedCount?: number;
totalCount?: number;
}
export function ReviewHeader({
phases,
activePhaseId,
onPhaseSelect,
phaseName,
sourceBranch,
targetBranch,
files,
status,
unresolvedCount,
onApprove,
onRequestChanges,
isRequestingChanges,
preview,
viewedCount,
totalCount,
}: ReviewHeaderProps) {
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
const [showConfirmation, setShowConfirmation] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null);
// Click-outside handler to dismiss confirmation
useEffect(() => {
if (!showConfirmation) return;
function handleClickOutside(e: MouseEvent) {
if (
confirmRef.current &&
!confirmRef.current.contains(e.target as Node)
) {
setShowConfirmation(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showConfirmation]);
const viewed = viewedCount ?? 0;
const total = totalCount ?? 0;
return (
<div className="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-20">
{/* Phase selector row */}
{phases.length > 1 && (
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mr-2 shrink-0">
Phases
</span>
<div className="flex gap-1 overflow-x-auto">
{phases.map((phase) => {
const isActive = phase.id === activePhaseId;
return (
<button
key={phase.id}
onClick={() => onPhaseSelect(phase.id)}
className={`
flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium
transition-all duration-fast whitespace-nowrap
${isActive
? "bg-primary/10 text-primary border border-primary/20 shadow-xs"
: "text-muted-foreground hover:text-foreground hover:bg-muted border border-transparent"
}
`}
>
<span
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
isActive ? "bg-primary" : "bg-status-warning-dot"
}`}
/>
{phase.name}
</button>
);
})}
</div>
</div>
)}
{/* Main toolbar row */}
<div className="flex items-center gap-3 px-4 py-2.5">
{/* Left: branch info + stats */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<h2 className="text-sm font-semibold truncate shrink-0">
{phaseName}
</h2>
{sourceBranch && (
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono min-w-0">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate" title={sourceBranch}>
{sourceBranch}
</span>
<ArrowRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
<span className="truncate" title={targetBranch}>
{targetBranch}
</span>
</div>
)}
<div className="flex items-center gap-2.5 text-[11px] shrink-0">
<span className="flex items-center gap-0.5 text-muted-foreground">
<FileCode className="h-3 w-3" />
{files.length}
</span>
<span className="flex items-center gap-0.5 text-diff-add-fg">
<Plus className="h-3 w-3" />
{totalAdditions}
</span>
<span className="flex items-center gap-0.5 text-diff-remove-fg">
<Minus className="h-3 w-3" />
{totalDeletions}
</span>
</div>
</div>
{/* Center: review progress */}
{total > 0 && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground shrink-0">
<Eye className="h-3 w-3" />
<span>
{viewed}/{total} viewed
</span>
</div>
)}
{/* Right: preview + actions */}
<div className="flex items-center gap-3 shrink-0">
{/* Preview controls */}
{preview && <PreviewControls preview={preview} />}
{/* Review status / actions */}
{status === "pending" && (
<>
<Button
variant="outline"
size="sm"
onClick={onRequestChanges}
disabled={isRequestingChanges}
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
>
{isRequestingChanges ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
Request Changes
</Button>
<div className="relative" ref={confirmRef}>
<Button
size="sm"
onClick={() => {
if (unresolvedCount > 0) return;
setShowConfirmation(true);
}}
disabled={unresolvedCount > 0}
className="h-9 px-5 text-sm font-semibold shadow-sm"
>
{unresolvedCount > 0 ? (
<>
<AlertCircle className="h-3.5 w-3.5" />
{unresolvedCount} unresolved
</>
) : (
<>
<GitMerge className="h-3.5 w-3.5" />
Approve & Merge
</>
)}
</Button>
{/* Merge confirmation dropdown */}
{showConfirmation && (
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
<p className="text-sm font-semibold mb-3">
Ready to merge?
</p>
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-status-success-fg" />
<span className="text-muted-foreground">
0 unresolved comments
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
{viewed}/{total} files viewed
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmation(false)}
className="h-8 text-xs"
>
Cancel
</Button>
<Button
size="sm"
onClick={() => {
setShowConfirmation(false);
onApprove();
}}
className="h-8 px-4 text-xs font-semibold shadow-sm"
>
<GitMerge className="h-3.5 w-3.5" />
Merge Now
</Button>
</div>
</div>
)}
</div>
</>
)}
{status === "approved" && (
<Badge variant="success" size="xs">
<Check className="h-3 w-3" />
Approved
</Badge>
)}
{status === "changes_requested" && (
<Badge variant="warning" size="xs">
<X className="h-3 w-3" />
Changes Requested
</Badge>
)}
</div>
</div>
</div>
);
}
function PreviewControls({ preview }: { preview: PreviewState }) {
if (preview.status === "building" || preview.isStarting) {
return (
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Building...</span>
</div>
);
}
if (preview.status === "running") {
return (
<div className="flex items-center gap-1.5">
<a
href={preview.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
>
<CircleDot className="h-3 w-3" />
Preview
<ExternalLink className="h-2.5 w-2.5" />
</a>
<Button
variant="ghost"
size="sm"
onClick={preview.onStop}
disabled={preview.isStopping}
className="h-6 w-6 p-0"
>
<Square className="h-2.5 w-2.5" />
</Button>
</div>
);
}
if (preview.status === "failed") {
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
className="h-7 text-xs text-status-error-fg"
>
<RotateCcw className="h-3 w-3" />
Retry Preview
</Button>
);
}
return (
<Button
variant="ghost"
size="sm"
onClick={preview.onStart}
disabled={preview.isStarting}
className="h-7 text-xs"
>
<ExternalLink className="h-3 w-3" />
Preview
</Button>
);
}