diff --git a/dashboard/src/v2/components/sprints/SprintReviewBadge.tsx b/dashboard/src/v2/components/sprints/SprintReviewBadge.tsx index 1703db9aaa..d167560ad7 100644 --- a/dashboard/src/v2/components/sprints/SprintReviewBadge.tsx +++ b/dashboard/src/v2/components/sprints/SprintReviewBadge.tsx @@ -1,7 +1,15 @@ import type { FunctionComponent } from "preact"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { createPortal } from "preact/compat"; -import { CheckCircle2, ListChecks, ChevronRight, Loader2 } from "lucide-preact"; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { + CheckCircle2, + ChevronRight, + ListChecks, + Loader2, + PencilLine, + XCircle, +} from "lucide-preact"; +import type { LucideIcon } from "lucide-preact"; import type { SprintReviewSummary } from "../../types.js"; import { calculatePosition, type Position } from "../../lib/positioning/index.js"; @@ -12,189 +20,427 @@ interface SprintReviewBadgeProps { showCompactLabel?: boolean; } +type ReviewState = "passed" | "changes_requested" | "failed"; + +interface ReviewPresentation { + state: ReviewState; + icon: LucideIcon; + badgeLabel: string; + compactLabel: string; + heading: string; + tone: string; + accent: string; + iconTone: string; +} + +const REVIEW_PRESENTATIONS: Record = { + passed: { + state: "passed", + icon: CheckCircle2, + badgeLabel: "QA passed", + compactLabel: "QA", + heading: "QA Review Passed", + tone: "border-signal-500/30 bg-signal-500/10 text-signal-600 dark:text-signal-300", + accent: "before:from-signal-500 before:via-signal-400 before:to-signal-500", + iconTone: "text-signal-500", + }, + changes_requested: { + state: "changes_requested", + icon: PencilLine, + badgeLabel: "QA changes requested", + compactLabel: "QA edits", + heading: "QA Changes Requested", + tone: "border-blue-500/35 bg-blue-500/10 text-blue-700 shadow-[0_8px_22px_rgba(59,130,246,0.10)] dark:text-blue-300", + accent: "before:from-blue-600 before:via-blue-400 before:to-blue-600", + iconTone: "text-blue-500", + }, + failed: { + state: "failed", + icon: XCircle, + badgeLabel: "QA review failed", + compactLabel: "QA failed", + heading: "QA Provider Review Failed", + tone: "border-status-red/30 bg-status-red/10 text-status-red shadow-[0_8px_22px_rgba(227,0,15,0.10)]", + accent: "before:from-status-red before:via-ember-400 before:to-status-red", + iconTone: "text-status-red", + }, +}; + +function getReviewPresentation(summary: SprintReviewSummary): ReviewPresentation { + const status = summary.status.toLowerCase(); + const outcome = summary.outcome?.toLowerCase() ?? ""; + + if (outcome === "changes_requested") { + return REVIEW_PRESENTATIONS.changes_requested; + } + if (status === "failed" || status === "errored" || status === "cancelled") { + return REVIEW_PRESENTATIONS.failed; + } + return REVIEW_PRESENTATIONS.passed; +} + +function formatReviewDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }).format(date); +} + +const FollowUpTaskDisclosure: FunctionComponent<{ + task: NonNullable[number]; + index: number; +}> = ({ task, index }) => { + const [isExpanded, setIsExpanded] = useState(false); + const contentId = useId(); + + return ( +
+ + {isExpanded && ( +
+
Title
+
{task.title}
+
Description
+
{task.description || "Not provided"}
+
Priority
+
{task.priority}
+
Dependencies
+
{task.dependsOnTaskKeys.length > 0 ? task.dependsOnTaskKeys.join(", ") : "None"}
+
Prompt
+
{task.promptMarkdown}
+
+ )} +
+ ); +}; + export const SprintReviewBadge: FunctionComponent = ({ summary, compact = false, align = "center", showCompactLabel = false, }) => { - const [overlayId] = useState(() => `sprint-review-overlay-${Math.random().toString(36).slice(2, 10)}`); + const overlayId = useId(); + const headingId = useId(); + const stateDescriptionId = useId(); const [isOpen, setIsOpen] = useState(false); - const triggerRef = useRef(null); + const triggerRef = useRef(null); const overlayRef = useRef(null); const closeTimeoutRef = useRef(null); + const pointerInsideRef = useRef(false); + const suppressFocusOpenRef = useRef(false); const [coords, setCoords] = useState({ top: 0, left: 0 }); - const hasFindings = Boolean(summary.findings && summary.findings.length > 0); + const presentation = getReviewPresentation(summary); + const StatusIcon = presentation.icon; + const findings = summary.findings ?? []; + const reviewHeading = presentation.state === "passed" && summary.outcome?.toLowerCase() === "approved" + ? "QA Review Complete" + : presentation.heading; const preferredPosition: Position = align === "right" ? "left" : "right"; + const hasDetails = Boolean( + summary.summary + || findings.length > 0 + || summary.fixInstructions + || summary.targetTaskKey + || summary.followUpTasks?.length + || summary.reviewer + || summary.finishedAt, + ); - const clearCloseTimeout = useCallback(() => { + const clearCloseTimeout = useCallback((): void => { if (closeTimeoutRef.current !== null) { window.clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } }, []); - const openOverlay = useCallback(() => { + const openOverlay = useCallback((): void => { clearCloseTimeout(); setIsOpen(true); }, [clearCloseTimeout]); - const closeOverlay = useCallback(() => { + const closeOverlay = useCallback((restoreFocus = false): void => { + clearCloseTimeout(); + setIsOpen(false); + if (restoreFocus) { + suppressFocusOpenRef.current = document.activeElement !== triggerRef.current; + triggerRef.current?.focus({ preventScroll: true }); + } + }, [clearCloseTimeout]); + + const scheduleClose = useCallback((): void => { clearCloseTimeout(); closeTimeoutRef.current = window.setTimeout(() => { - setIsOpen(false); + const activeElement = document.activeElement; + const focusIsInside = Boolean( + activeElement + && (triggerRef.current?.contains(activeElement) || overlayRef.current?.contains(activeElement)), + ); + if (!pointerInsideRef.current && !focusIsInside) { + setIsOpen(false); + } closeTimeoutRef.current = null; }, 120); }, [clearCloseTimeout]); - const updateOverlayPosition = useCallback(() => { + const updateOverlayPosition = useCallback((): void => { if (!triggerRef.current || !overlayRef.current) return; - const nextCoords = calculatePosition({ + setCoords(calculatePosition({ triggerRect: triggerRef.current.getBoundingClientRect(), contentRect: overlayRef.current.getBoundingClientRect(), position: preferredPosition, align: "center", gap: 10, padding: 12, - }); - - setCoords(nextCoords); + })); }, [preferredPosition]); useLayoutEffect(() => { - if (!isOpen) return; - updateOverlayPosition(); + if (isOpen) { + updateOverlayPosition(); + } }, [isOpen, updateOverlayPosition]); useEffect(() => { if (!isOpen) return undefined; + const handleOutsidePointer = (event: Event): void => { + const target = event.target; + if (!(target instanceof Node)) return; + if (!triggerRef.current?.contains(target) && !overlayRef.current?.contains(target)) { + closeOverlay(); + } + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + event.preventDefault(); + closeOverlay(true); + } + }; + + document.addEventListener("mousedown", handleOutsidePointer); + document.addEventListener("touchstart", handleOutsidePointer, { passive: true }); + document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", updateOverlayPosition); window.addEventListener("scroll", updateOverlayPosition, { capture: true, passive: true }); return () => { + document.removeEventListener("mousedown", handleOutsidePointer); + document.removeEventListener("touchstart", handleOutsidePointer); + document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", updateOverlayPosition); window.removeEventListener("scroll", updateOverlayPosition, { capture: true }); }; - }, [isOpen, updateOverlayPosition]); + }, [closeOverlay, isOpen, updateOverlayPosition]); useEffect(() => () => clearCloseTimeout(), [clearCloseTimeout]); - if (summary.status === 'running') { + if (summary.status.toLowerCase() === "running") { return ( -
+
- + {(!compact || showCompactLabel) && {compact ? "QA" : "Reviewing..."}}
); } + const handleFocusLeaving = (relatedTarget: EventTarget | null): void => { + if (!(relatedTarget instanceof Node)) { + scheduleClose(); + return; + } + if (!triggerRef.current?.contains(relatedTarget) && !overlayRef.current?.contains(relatedTarget)) { + scheduleClose(); + } + }; + return ( -
{ + pointerInsideRef.current = true; + openOverlay(); + }} + onMouseLeave={() => { + pointerInsideRef.current = false; + scheduleClose(); + }} > -
+ {presentation.badgeLabel}. {isOpen ? "Details are open." : "Activate to show review details."} + +
+ + {(!compact || showCompactLabel) && ( + {compact ? presentation.compactLabel : presentation.badgeLabel} + )} + {isOpen && createPortal(