"use client"; import { useRef, useState } from "react"; import { IconCircleCheckFilled, IconCircleXFilled, IconChecklist, IconLoader2, IconPointFilled, IconX, } from "@tabler/icons-react"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@kandev/ui/drawer"; import { Button } from "@kandev/ui/button"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-popover"; import { PRCIPopover } from "@/components/github/pr-ci-popover"; import { isPRAwaitingReview, isPRReadyToMerge } from "@/components/github/pr-task-icon"; import { useIsMobile } from "@/hooks/use-mobile"; import type { TaskPR } from "@/lib/types/github"; const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 150; // Terminal states (merged / closed) never reach here — PRStatusChip returns // null for them before rendering — so the chip status union omits them. type ChipStatus = "passed" | "failed" | "in_progress" | "neutral"; function chipStatus(pr: TaskPR): ChipStatus { if (pr.review_state === "changes_requested" || pr.checks_state === "failure") return "failed"; // Pending checks / pending review must beat checks_state === "success" so a // PR with all checks green but reviewers still outstanding renders as // in-progress, not passed. Without this order, the chip flips to green the // moment CI finishes and ignores the human gate. isPRAwaitingReview also // covers approved PRs where branch protection requires more reviewers. if (pr.checks_state === "pending" || pr.review_state === "pending") return "in_progress"; // Mirror getPRStatusColor priority: ready-to-merge beats awaiting-review so // the chip and icon never disagree on a (theoretical) clean+approved+pending PR. if (isPRAwaitingReview(pr) && !isPRReadyToMerge(pr)) return "in_progress"; if (pr.checks_state === "success") return "passed"; return "neutral"; } /** * Compact CI indicator for the chat status bar — a "CI" prefix icon plus a * status glyph that mirrors the popover's bucket colors: * passed → green check * failed → red X * in progress → yellow spinner * neutral → muted dot * * Desktop: hovering opens the full PRCIPopover anchored to the top edge so the * card expands upward (the chip sits just above the chat input). * * Mobile: tapping opens the same popover content inside a bottom-sheet Drawer * — hover is unreachable on touch devices. * * Returns null when the task has no PR yet, or once the PR reaches a terminal * state (merged / closed) — the chat-input banner already conveys that, so the * CI chip would be redundant. */ export function PRStatusChip({ taskId }: { taskId: string | null }) { const { pr } = useTaskPR(taskId); // Subscribe at the chip level so the cache warms even when the // top-bar PR button isn't mounted (e.g. small viewport that hides it). usePRFeedbackBackgroundSync(pr); if (!pr) return null; if (pr.state === "merged" || pr.state === "closed") return null; return ; } type ChipButtonAttrs = { "data-testid": "pr-status-chip"; "data-pr-number": number; "data-pr-state": string; "data-status": ChipStatus; "data-pr-ready-to-merge": "true" | "false"; "aria-label": string; className: string; }; function chipButtonAttrs(pr: TaskPR, status: ChipStatus): ChipButtonAttrs { return { "data-testid": "pr-status-chip", "data-pr-number": pr.pr_number, "data-pr-state": pr.state, "data-status": status, "data-pr-ready-to-merge": isPRReadyToMerge(pr) ? "true" : "false", "aria-label": `Pull request #${pr.pr_number} CI status`, className: "cursor-pointer inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs", }; } function PRStatusChipInner({ pr }: { pr: TaskPR }) { const isMobile = useIsMobile(); if (isMobile) return ; return ; } function PRStatusChipHoverCard({ pr }: { pr: TaskPR }) { const status = chipStatus(pr); const triggerRef = useRef(null); return ( { // Radix HoverCard treats the trigger as outside the content's // bounding box, so a click on the chip would auto-close the // popover. Filter out trigger clicks so clicking the chip is // a no-op while the popover stays open via hover. if (triggerRef.current && triggerRef.current.contains(e.target as Node)) { e.preventDefault(); } }} > ); } function PRStatusChipDrawer({ pr }: { pr: TaskPR }) { const status = chipStatus(pr); const [open, setOpen] = useState(false); return ( PR #{pr.pr_number} Pull request CI status, reviews, and checks summary.
); } function ChipStatusGlyph({ status }: { status: ChipStatus }) { switch (status) { case "passed": return