"use client"; import { useCallback, useRef, useState } from "react"; import { IconCircleCheckFilled, IconCircleXFilled, IconChecklist, IconClock, IconLoader2, IconPointFilled, IconAlertTriangleFilled, IconShield, IconX, } from "@tabler/icons-react"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@kandev/ui/drawer"; import { Button } from "@kandev/ui/button"; import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useHoverPopover } from "@/hooks/domains/github/use-hover-popover"; import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-popover"; import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS, PRCIPopover } from "@/components/github/pr-ci-popover"; import { useTaskCIAutomationOptions } from "@/hooks/domains/github/use-task-ci-options"; import { MultiPRCIPopover } from "@/components/github/multi-pr-ci-popover"; import { isPRAwaitingReview, isPRReadyToMerge, isPRWaitingOnBranchProtection, pickDefaultPR, } from "@/components/github/pr-task-icon"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; 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" | "conflict" | "blocked" | "behind" | "waiting" | "in_progress" | "neutral"; type AutomationFlags = { autoFix: boolean; autoMerge: boolean; }; function hasUnknownOrInProgressChecks(pr: TaskPR): boolean { return pr.checks_total <= 0 || pr.checks_passing < pr.checks_total; } function chipStatus(pr: TaskPR): ChipStatus { if (pr.review_state === "changes_requested" || pr.checks_state === "failure") return "failed"; // Merge conflicts / behind-base block the merge even when CI is green — the // chip must never read as a passed check in that case. Mirrors // getPRStatusColor + PRStatusIcon (dirty = red, behind = amber). if (pr.mergeable_state === "dirty") return "conflict"; if (pr.mergeable_state === "behind") return "behind"; // 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" && hasUnknownOrInProgressChecks(pr)) || 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 (isPRWaitingOnBranchProtection(pr)) return "waiting"; if (pr.mergeable_state === "blocked") return "blocked"; 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/conflicting PR colours the whole chip. const CHIP_STATUS_RANK: Record = { failed: 6, conflict: 5, blocked: 4, behind: 3, in_progress: 2, waiting: 1.5, 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 }; } // Hover-bridge lifecycle for the chip's desktop popover. Delegates to the // shared hook so the chip and the top-bar PR button keep identical // trigger->content bridge behavior (the popover must survive the cursor // crossing the sideOffset gap). The chip's mobile surface is a Drawer, so no // mobile guard is needed here. function useChipPopoverInteractions() { return useHoverPopover({ openDelayMs: HOVER_OPEN_DELAY_MS, closeDelayMs: HOVER_CLOSE_DELAY_MS, }); } /** * 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); const { options: automationOptions } = useTaskCIAutomationOptions(taskId); const automationFlags: AutomationFlags = { autoFix: Boolean(automationOptions?.auto_fix_enabled), autoMerge: Boolean(automationOptions?.auto_merge_enabled), }; // 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 automationAriaSuffix(automation: AutomationFlags): string { const flags = [ automation.autoFix ? "auto-fix enabled" : null, automation.autoMerge ? "auto-merge enabled" : null, ].filter(Boolean); return flags.length > 0 ? `, ${flags.join(", ")}` : ""; } function chipButtonAttrs( pr: TaskPR, status: ChipStatus, automation: AutomationFlags, ): 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${automationAriaSuffix(automation)}`, className: CHIP_BUTTON_CLASS, }; } function AutomationFlagBadges({ automation }: { automation: AutomationFlags }) { if (!automation.autoFix && !automation.autoMerge) return null; return ( <> {automation.autoFix && ( Auto-fix )} {automation.autoMerge && ( Auto-merge )} ); } function PRStatusChipInner({ pr, automation }: { pr: TaskPR; automation: AutomationFlags }) { const usesMobileDrawer = useTouchDrawer(); if (usesMobileDrawer) return ; return ; } function PRStatusChipHoverCard({ pr, automation }: { pr: TaskPR; automation: AutomationFlags }) { const status = chipStatus(pr); const { ref, onPointerDownOutside } = useChipTriggerGuard(); const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } = useChipPopoverInteractions(); return ( e.preventDefault()} > ); } function PRStatusChipMultiInner({ prs, automation, }: { prs: TaskPR[]; automation: AutomationFlags; }) { const usesMobileDrawer = useTouchDrawer(); if (usesMobileDrawer) 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, automation: AutomationFlags, ): MultiChipButtonAttrs { return { "data-testid": "pr-status-chip", "data-pr-count": prs.length, "data-status": status, "aria-label": `${prs.length} pull requests CI status${automationAriaSuffix(automation)}`, className: CHIP_BUTTON_CLASS, }; } function MultiChipGlyph({ prs, status, automation, }: { prs: TaskPR[]; status: ChipStatus; automation: AutomationFlags; }) { return ( <>