"use client"; import { memo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IconGitPullRequest, IconCheck, IconX, IconClock, IconChevronDown, IconAlertTriangle, } from "@tabler/icons-react"; 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 { useHoverPopover } from "@/hooks/domains/github/use-hover-popover"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { aggregatePRStatusColor, getPRStatusColor, hasPRChecksInProgressForDisplay, hasPRChecksPassedWithoutReviewWaitForDisplay, isPRDraft, isPRAwaitingReview, isPRReadyToMerge, isPRWaitingOnBranchProtection, } from "@/components/github/pr-task-icon"; import { prIdentitySlug, prTaskKey } from "@/components/github/pr-utils"; import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS, PRCIPopover } from "@/components/github/pr-ci-popover"; import { MultiPRCIPopover } from "@/components/github/multi-pr-ci-popover"; import { useAppStore } from "@/components/state-provider"; import { ChangeRequestTopbarButton, ChangeRequestTopbarContent, } from "@/components/integrations/change-request-status-chrome"; import type { TaskPR } from "@/lib/types/github"; const POPOVER_OPEN_DELAY_MS = 150; const POPOVER_CLOSE_DELAY_MS = 150; type TriggerRef = { current: HTMLButtonElement | null }; function focusAfterCollapse(triggerRef?: TriggerRef) { if (triggerRef) setTimeout(() => triggerRef.current?.focus(), 0); } // Badge for the hard merge blockers that must beat ready/awaiting-review: // conflicts (red) and behind-base (amber). Mirrors openMergeBlockerColor so // the badge agrees with the pill colour. Returns null otherwise. ("blocked" // is handled later, after awaiting-review.) function mergeBlockerBadge(pr: TaskPR): ReactNode | null { if (pr.state !== "open") return null; if (pr.mergeable_state === "dirty") { return ; } if (pr.mergeable_state === "behind") { return ; } return null; } 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 (isPRDraft(pr)) { return ; } const blockerBadge = mergeBlockerBadge(pr); if (blockerBadge) return blockerBadge; 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 (isPRWaitingOnBranchProtection(pr)) { return ; } if (hasPRChecksPassedWithoutReviewWaitForDisplay(pr)) { return ; } if (hasPRChecksInProgressForDisplay(pr) || 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, refresh, unlink } = useTaskPR(activeTaskId); const triggerRef = useRef(null); 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. * * The hover-bridge logic (keeping the popover open while the cursor crosses * from the trigger onto the portalled content) lives in the shared * {@link useHoverPopover} hook so the chip and this button stay in sync. */ function usePopoverInteractions() { const usesTouchDrawer = useTouchDrawer(); const hover = useHoverPopover({ openDelayMs: POPOVER_OPEN_DELAY_MS, closeDelayMs: POPOVER_CLOSE_DELAY_MS, disabled: usesTouchDrawer, }); return { usesTouchDrawer, ...hover }; } function PRSingleButton({ pr, refreshTaskPR, triggerRef, }: { pr: TaskPR; refreshTaskPR: () => void | Promise; triggerRef?: TriggerRef; }) { const addPRPanel = useDockviewStore((s) => s.addPRPanel); const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} - ${pr.pr_title}`; const { usesTouchDrawer, open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave, } = 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); }} > } /> ); if (usesTouchDrawer) { return ( {button} {tooltip} ); } return ( {button} e.preventDefault()} > ); } function PRMultiButton({ prs, refreshTaskPR, onRemovePR, triggerRef, }: { prs: TaskPR[]; refreshTaskPR: () => void | Promise; onRemovePR: (associationId: string) => Promise; triggerRef?: TriggerRef; }) { const { t } = useTranslation(); // Click still drives the dropdown (the explicit "jump to this PR's panel" // affordance, and the only interaction on touch). Hover adds the aggregate // CI popover with a tab per PR — desktop only, suppressed on touch where // there is no hover. const addPRPanel = useDockviewStore((s) => s.addPRPanel); const { usesTouchDrawer, open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave, } = usePopoverInteractions(); const [menuOpen, setMenuOpen] = useState(false); const aggColor = aggregatePRStatusColor(prs); // The trigger is the click target for the dropdown AND the hover anchor for // the popover. On desktop we wrap it in a PopoverAnchor so all the asChild // layers (Tooltip → Popover → Dropdown) collapse onto the single Button and // the popover positions against it. While the dropdown is open the hover // popover is closed and hover re-opens are suppressed, so the two overlays // never stack. const triggerButton = ( } /> ); const dropdown = ( { setMenuOpen(next); if (next) onOpenChange(false); }} > {usesTouchDrawer ? triggerButton : {triggerButton}} {t("github:pullRequestsLinkedToTask", { count: prs.length })} ); if (usesTouchDrawer) return dropdown; return ( {dropdown} e.preventDefault()} > onRemovePR(pr.id)} onCollapseFocus={() => focusAfterCollapse(triggerRef)} onOpenDetailPanel={(pr) => { addPRPanel(prTaskKey(pr)); onOpenChange(false); }} /> ); } function MultiPRMenuContent({ prs }: { prs: TaskPR[] }) { const { t } = useTranslation(); const addPRPanel = useDockviewStore((s) => s.addPRPanel); return ( {t("github:pullRequests")} {prs.map((pr) => ( addPRPanel(prTaskKey(pr))} className="cursor-pointer gap-2" data-testid={`pr-topbar-menu-item-${prIdentitySlug(pr)}`} >
{pr.repo} #{pr.pr_number} {pr.pr_title}
))}
); }