"use client"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { IconCircleCheckFilled, IconCircleXFilled, IconChecklist, IconClock, IconLoader2, IconPointFilled, IconAlertTriangleFilled, IconShield, } from "@tabler/icons-react"; import { Drawer } from "@kandev/ui/drawer"; 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 { useAppStore } from "@/components/state-provider"; import { hasPRChecksInProgressForDisplay, hasPRChecksPassedForDisplay, isPRDraft, isPRAwaitingReview, isPRReadyToMerge, isPRQueued, isPRWaitingOnBranchProtection, pickDefaultPR, } from "@/components/github/pr-task-icon"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { ChangeRequestStatusChip, ChangeRequestStatusChipHoverArea, ChangeRequestStatusDrawerContent, useChangeRequestStatusChipTriggerGuard, } from "@/components/integrations/change-request-status-chrome"; import { AutomationFlagBadges, automationAriaSuffix, automationForPR, automationForPRs, } from "@/components/github/pr-status-automation-badges"; import type { AutomationFlags } from "@/components/github/pr-status-automation-badges"; import type { TaskPR } from "@/lib/types/github"; import type { TFunction } from "i18next"; 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" | "draft" | "waiting" | "queued" | "in_progress" | "neutral"; type TriggerRef = { current: HTMLButtonElement | null }; type SingleChipProps = { pr: TaskPR; automation: AutomationFlags; refreshTaskPR: () => void | Promise; triggerRef?: TriggerRef; }; type MultiChipProps = { prs: TaskPR[]; statusPrs?: TaskPR[]; automation: AutomationFlags; refreshTaskPR: () => void | Promise; onRemovePR?: (pr: TaskPR) => Promise; triggerRef?: TriggerRef; }; function focusAfterCollapse(triggerRef?: TriggerRef) { if (triggerRef) setTimeout(() => triggerRef.current?.focus(), 0); } function chipStatus(pr: TaskPR): ChipStatus { // Terminal PRs are filtered before this helper. For active PRs, queue // membership is the authoritative non-terminal state, so stale failure, // draft, or mergeability fields cannot override the queue indicator. if (isPRQueued(pr)) return "queued"; if (pr.review_state === "changes_requested" || pr.checks_state === "failure") return "failed"; if (isPRDraft(pr)) return "draft"; // 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 (hasPRChecksInProgressForDisplay(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 (hasPRChecksPassedForDisplay(pr)) 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, queued: 2.5, draft: 0.5, 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; } /** * 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). */ // 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 workspaceId = useAppStore((state) => state.workspaces.activeId); const { prs, refresh, unlink } = useTaskPR(taskId); const { options: automationOptions } = useTaskCIAutomationOptions(taskId); const triggerRef = useRef(null); // 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 allPRs = Array.isArray(prs) ? prs : []; const openPRs = allPRs.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(workspaceId, pickDefaultPR(openPRs)); if (allPRs.length === 0 || (allPRs.length === 1 && openPRs.length === 0)) return null; if (allPRs.length === 1) return ( ); return ( unlink(pr.id)} triggerRef={triggerRef} /> ); } 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; }; function chipButtonAttrs( pr: TaskPR, status: ChipStatus, automation: AutomationFlags, t: TFunction, ): 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": t("github:pullRequestCiStatusAria", { number: pr.pr_number, automation: automationAriaSuffix(automation, t), }), }; } function PRStatusChipInner(props: SingleChipProps) { const usesMobileDrawer = useTouchDrawer(); if (usesMobileDrawer) return ; return ; } function PRStatusChipHoverCard({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) { const { t } = useTranslation(); const status = chipStatus(pr); const { ref, onPointerDownOutside } = useChangeRequestStatusChipTriggerGuard(triggerRef); const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } = useChipPopoverInteractions(); return ( e.preventDefault()} > ); } function PRStatusChipMultiInner(props: MultiChipProps) { const usesMobileDrawer = useTouchDrawer(); if (usesMobileDrawer) return ; return ; } type MultiChipButtonAttrs = { "data-testid": "pr-status-chip"; "data-pr-count": number; "data-status": ChipStatus; "aria-label": string; }; function multiChipButtonAttrs( prs: TaskPR[], status: ChipStatus, automation: AutomationFlags, t: TFunction, ): MultiChipButtonAttrs { return { "data-testid": "pr-status-chip", "data-pr-count": prs.length, "data-status": status, "aria-label": t("github:pullRequestCountCiStatusAria", { count: prs.length, automation: automationAriaSuffix(automation, t), }), }; } function MultiChipGlyph({ prs, status, automation, }: { prs: TaskPR[]; status: ChipStatus; automation: AutomationFlags; }) { return ( <>