"use client"; import { useCallback, 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 { MultiPRCIPopover } from "@/components/github/multi-pr-ci-popover"; import { isPRAwaitingReview, isPRReadyToMerge, pickDefaultPR, } 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"; } // Higher = more attention-worthy. Drives the aggregate glyph when a task has // multiple open PRs — one failing PR colours the whole chip red. const CHIP_STATUS_RANK: Record = { failed: 3, in_progress: 2, passed: 1, neutral: 0, }; export function aggregateChipStatus(prs: TaskPR[]): ChipStatus { let worst: ChipStatus = "neutral"; for (const pr of prs) { const status = chipStatus(pr); if (CHIP_STATUS_RANK[status] > CHIP_STATUS_RANK[worst]) worst = status; } return worst; } const CHIP_BUTTON_CLASS = "cursor-pointer inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs"; /** * Radix HoverCard treats the trigger as outside the content's bounding box, so * a click on the chip would auto-close the popover. This guard filters out * trigger clicks so clicking the chip is a no-op while the popover stays open * via hover. Returns the trigger ref plus a memoised handler that reads the ref * lazily (inside the callback, never during render). */ function useChipTriggerGuard() { const ref = useRef(null); const onPointerDownOutside = useCallback( (e: { target: EventTarget | null; preventDefault: () => void }) => { if (ref.current && ref.current.contains(e.target as Node)) { e.preventDefault(); } }, [], ); return { ref, onPointerDownOutside }; } /** * 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 { prs } = useTaskPR(taskId); // Defensive Array.isArray: a partial hydration can briefly seed the store // with a non-array value (same guard as PRTaskIcon). // Only open PRs are worth a CI chip — terminal PRs (merged/closed) are // already conveyed by the chat-input banner. With multiple PRs the chip // stays visible as long as at least one is still open. const openPRs = Array.isArray(prs) ? prs.filter((p) => p.state !== "merged" && p.state !== "closed") : []; // 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). Warm the PR the // popover will actually open first (worst-status via pickDefaultPR — for a // single PR that's just the PR itself); the remaining PRs in a multi-PR // task warm when the popover opens. usePRFeedbackBackgroundSync(pickDefaultPR(openPRs)); if (openPRs.length === 0) return null; if (openPRs.length === 1) return ; 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: CHIP_BUTTON_CLASS, }; } function PRStatusChipInner({ pr }: { pr: TaskPR }) { const isMobile = useIsMobile(); if (isMobile) return ; return ; } function PRStatusChipHoverCard({ pr }: { pr: TaskPR }) { const status = chipStatus(pr); const { ref, onPointerDownOutside } = useChipTriggerGuard(); return ( ); } function PRStatusChipMultiInner({ prs }: { prs: TaskPR[] }) { const isMobile = useIsMobile(); if (isMobile) return ; return ; } type MultiChipButtonAttrs = { "data-testid": "pr-status-chip"; "data-pr-count": number; "data-status": ChipStatus; "aria-label": string; className: string; }; function multiChipButtonAttrs(prs: TaskPR[], status: ChipStatus): MultiChipButtonAttrs { return { "data-testid": "pr-status-chip", "data-pr-count": prs.length, "data-status": status, "aria-label": `${prs.length} pull requests CI status`, className: CHIP_BUTTON_CLASS, }; } function MultiChipGlyph({ prs, status }: { prs: TaskPR[]; status: ChipStatus }) { return ( <>