"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)}`}
>