"use client"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { IconGitPullRequest, IconCheck, IconX, IconClock, IconChevronDown, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useIsMobile } from "@/hooks/use-mobile"; import { aggregatePRStatusColor, getPRStatusColor, isPRAwaitingReview, isPRReadyToMerge, } from "@/components/github/pr-task-icon"; import { prTaskKey } from "@/components/github/pr-detail-panel"; import { PRCIPopover } from "@/components/github/pr-ci-popover"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; const POPOVER_OPEN_DELAY_MS = 150; const POPOVER_CLOSE_DELAY_MS = 150; function PRStatusIcon({ pr }: { pr: TaskPR }) { // Terminal states take priority if (pr.state === "merged") { return ; } if (pr.state === "closed") { return ; } // Review/check states only matter for open PRs if (pr.checks_state === "failure" || pr.review_state === "changes_requested") { return ; } if (isPRReadyToMerge(pr)) { return ; } // Check awaiting-review before the plain approved check so an approved PR // with pending reviewers (1 of N required) doesn't read as fully approved. if (isPRAwaitingReview(pr)) { return ; } if (pr.checks_state === "success" && pr.review_state === "approved") { return ; } if (pr.checks_state === "pending" || pr.review_state === "pending") { return ; } return null; } export const PRTopbarButton = memo(function PRTopbarButton() { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); // useTaskPR fetches if not in store and returns the full per-task list so // multi-repo tasks can surface every PR (one button for single-repo, a // dropdown summary for 2+ so the topbar doesn't blow up horizontally). const { prs } = useTaskPR(activeTaskId); if (prs.length === 0) return null; if (prs.length === 1) return ; return ; }); /** * Manages the hover-driven popover lifecycle. Click on the button is left * to the caller — desktop preserves the existing "open the PR detail panel" * behavior, and hover is what reveals the CI popover. On touch devices the * popover is suppressed entirely so the button click falls through to the * existing detail-panel handler. */ function usePopoverInteractions() { const isMobile = useIsMobile(); const [open, setOpen] = useState(false); const openTimer = useRef | null>(null); const closeTimer = useRef | null>(null); const clearOpen = useCallback(() => { if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; } }, []); const clearClose = useCallback(() => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } }, []); const handleEnter = useCallback(() => { if (isMobile) return; clearClose(); openTimer.current = setTimeout(() => setOpen(true), POPOVER_OPEN_DELAY_MS); }, [isMobile, clearClose]); const handleLeave = useCallback(() => { if (isMobile) return; clearOpen(); closeTimer.current = setTimeout(() => setOpen(false), POPOVER_CLOSE_DELAY_MS); }, [isMobile, clearOpen]); useEffect( () => () => { clearOpen(); clearClose(); }, [clearOpen, clearClose], ); return { isMobile, open, onOpenChange: (next: boolean) => { if (!next) { clearOpen(); clearClose(); setOpen(false); } }, handleEnter, handleLeave, }; } function PRSingleButton({ pr }: { pr: TaskPR }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`; const { isMobile, open, onOpenChange, handleEnter, handleLeave } = usePopoverInteractions(); // Background sync lives on PRStatusChip (always mounted in the chat // input area); the chip and this popover share prFeedbackCache so a // single subscription warms both. const button = ( { addPRPanel(prTaskKey(pr)); onOpenChange(false); }} > #{pr.pr_number} ); if (isMobile) { return ( {button} {tooltip} ); } return ( {button} e.preventDefault()} > ); } function PRMultiButton({ prs }: { prs: TaskPR[] }) { // Multi-PR keeps the original dropdown semantics on every form factor — // the popover lives inside the dropdown trigger via hover only, and the // per-PR rows are the click targets that addPRPanel. return ; } function PRMultiDropdown({ prs }: { prs: TaskPR[] }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); const aggColor = aggregatePRStatusColor(prs); return ( {prs.length} PRs {prs.length} pull requests linked to this task — open one Pull requests {prs.map((pr) => ( addPRPanel(prTaskKey(pr))} className="cursor-pointer gap-2" data-testid={`pr-topbar-menu-item-${pr.pr_number}`} > {pr.repo} #{pr.pr_number} {pr.pr_title} ))} ); }