Files
kandev/apps/web/components/github/pr-status-chip.tsx
T

199 lines
7.4 KiB
TypeScript

"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 <PRStatusChipInner pr={pr} />;
}
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 <PRStatusChipDrawer pr={pr} />;
return <PRStatusChipHoverCard pr={pr} />;
}
function PRStatusChipHoverCard({ pr }: { pr: TaskPR }) {
const status = chipStatus(pr);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<HoverCard openDelay={HOVER_OPEN_DELAY_MS} closeDelay={HOVER_CLOSE_DELAY_MS}>
<HoverCardTrigger asChild>
<button ref={triggerRef} type="button" {...chipButtonAttrs(pr, status)}>
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<ChipStatusGlyph status={status} />
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="start"
sideOffset={8}
className="w-80 p-2.5"
onPointerDownOutside={(e) => {
// 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();
}
}}
>
<PRCIPopover pr={pr} enabled={true} />
</HoverCardContent>
</HoverCard>
);
}
function PRStatusChipDrawer({ pr }: { pr: TaskPR }) {
const status = chipStatus(pr);
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
{...chipButtonAttrs(pr, status)}
>
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<ChipStatusGlyph status={status} />
</button>
<DrawerContent data-testid="pr-status-chip-drawer" className="max-h-[80vh] flex flex-col">
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2">
<DrawerTitle className="text-sm">PR #{pr.pr_number}</DrawerTitle>
<DrawerDescription className="sr-only">
Pull request CI status, reviews, and checks summary.
</DrawerDescription>
<DrawerClose asChild>
<Button
data-testid="pr-status-chip-drawer-close"
variant="ghost"
size="icon-sm"
aria-label="Close PR status"
className="cursor-pointer"
>
<IconX className="h-4 w-4" />
</Button>
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto p-3" data-vaul-no-drag>
<PRCIPopover pr={pr} enabled={open} />
</div>
</DrawerContent>
</Drawer>
);
}
function ChipStatusGlyph({ status }: { status: ChipStatus }) {
switch (status) {
case "passed":
return <IconCircleCheckFilled className="h-3.5 w-3.5 text-green-500" aria-hidden="true" />;
case "failed":
return <IconCircleXFilled className="h-3.5 w-3.5 text-red-500" aria-hidden="true" />;
case "in_progress":
// CI runs take minutes, so slow the spin to ~3s/rotation — the default
// animate-spin (1s) feels frantic for a long-running task.
return (
<IconLoader2
className="h-3.5 w-3.5 text-yellow-500 animate-spin [animation-duration:3s]"
aria-hidden="true"
/>
);
default:
return <IconPointFilled className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />;
}
}