Files
kandev/apps/web/components/github/pr-status-chip.tsx
T
2026-08-12 08:16:00 +01:00

560 lines
19 KiB
TypeScript

"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 { useAppStore } from "@/components/state-provider";
import {
hasPRChecksInProgressForDisplay,
hasPRChecksPassedForDisplay,
isPRDraft,
isPRAwaitingReview,
isPRReadyToMerge,
isPRWaitingOnBranchProtection,
pickDefaultPR,
} from "@/components/github/pr-task-icon";
import { useTouchDrawer } from "@/hooks/use-compact-task-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";
import { useTranslation } from "react-i18next";
const HOVER_OPEN_DELAY_MS = 150;
const HOVER_CLOSE_DELAY_MS = 150;
// Terminal states (merged / closed) are omitted from CI status aggregation,
// but remain in multi-PR surfaces so users can unlink old associations.
type ChipStatus =
| "passed"
| "failed"
| "conflict"
| "blocked"
| "behind"
| "draft"
| "waiting"
| "in_progress"
| "neutral";
type TriggerRef = { current: HTMLButtonElement | null };
type SingleChipProps = {
pr: TaskPR;
automation: AutomationFlags;
refreshTaskPR: () => void | Promise<void>;
triggerRef?: TriggerRef;
};
type MultiChipProps = {
prs: TaskPR[];
statusPrs?: TaskPR[];
automation: AutomationFlags;
refreshTaskPR: () => void | Promise<void>;
onRemovePR?: (pr: TaskPR) => Promise<void>;
triggerRef?: TriggerRef;
};
function focusAfterCollapse(triggerRef?: TriggerRef) {
if (!triggerRef) return;
setTimeout(() => triggerRef.current?.focus(), 0);
}
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";
if (isPRDraft(pr)) return "draft";
// 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<ChipStatus, number> = {
failed: 6,
conflict: 5,
blocked: 4,
behind: 3,
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;
}
const CHIP_BUTTON_CLASS =
"cursor-pointer inline-flex shrink-0 items-center gap-1 whitespace-nowrap 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(externalRef?: TriggerRef) {
const fallbackRef = useRef<HTMLButtonElement>(null);
const ref = externalRef ?? fallbackRef;
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 when its only PR is terminal
* (merged / closed). With multiple associations, terminal PRs stay in the
* multi-PR surface so old links can still be removed while CI status continues
* to reflect open PRs only.
*/
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<HTMLButtonElement>(null);
// Defensive Array.isArray: a partial hydration can briefly seed the store
// with a non-array value (same guard as PRTaskIcon).
const allPRs = Array.isArray(prs) ? prs : [];
// Terminal PRs are excluded from CI status and background warming, but are
// kept in the multi-PR association list so old links remain unlinkable.
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 (
<PRStatusChipInner
pr={openPRs[0]}
automation={automationForPR(automationOptions, openPRs[0])}
refreshTaskPR={refresh}
triggerRef={triggerRef}
/>
);
return (
<PRStatusChipMultiInner
prs={allPRs}
statusPrs={openPRs}
automation={automationForPRs(automationOptions, openPRs)}
refreshTaskPR={refresh}
onRemovePR={(pr) => 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;
className: 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),
}),
className: CHIP_BUTTON_CLASS,
};
}
function PRStatusChipInner(props: SingleChipProps) {
const usesMobileDrawer = useTouchDrawer();
if (usesMobileDrawer) return <PRStatusChipDrawer {...props} />;
return <PRStatusChipHoverCard {...props} />;
}
function PRStatusChipHoverCard({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
const { t } = useTranslation();
const status = chipStatus(pr);
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
useChipPopoverInteractions();
return (
<Popover open={open} onOpenChange={onOpenChange}>
<span
className="inline-flex"
onMouseOver={onTriggerEnter}
onMouseEnter={onTriggerEnter}
onMouseMove={onTriggerEnter}
onPointerOver={onTriggerEnter}
onPointerEnter={onTriggerEnter}
onPointerMove={onTriggerEnter}
onMouseLeave={onTriggerLeave}
onPointerLeave={onTriggerLeave}
onFocus={onTriggerEnter}
onBlur={onTriggerLeave}
>
<PopoverAnchor asChild>
<button ref={ref} type="button" {...chipButtonAttrs(pr, status, automation, t)}>
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<ChipStatusGlyph status={status} />
<AutomationFlagBadges automation={automation} />
</button>
</PopoverAnchor>
</span>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className={`w-80 p-2.5 ${PR_CI_DESKTOP_POPOVER_SCROLL_CLASS}`}
onMouseEnter={onContentEnter}
onMouseMove={onContentEnter}
onMouseLeave={onContentLeave}
onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<PRCIPopover pr={pr} enabled={open} refreshTaskPR={refreshTaskPR} />
</PopoverContent>
</Popover>
);
}
function PRStatusChipMultiInner(props: MultiChipProps) {
const usesMobileDrawer = useTouchDrawer();
if (usesMobileDrawer) return <PRStatusChipMultiDrawer {...props} />;
return <PRStatusChipMultiHoverCard {...props} />;
}
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,
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),
}),
className: CHIP_BUTTON_CLASS,
};
}
function MultiChipGlyph({
prs,
status,
automation,
}: {
prs: TaskPR[];
status: ChipStatus;
automation: AutomationFlags;
}) {
return (
<>
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<ChipStatusGlyph status={status} />
<span className="text-[9px] font-semibold leading-none tabular-nums">{prs.length}</span>
<AutomationFlagBadges automation={automation} />
</>
);
}
function PRStatusChipMultiHoverCard({
prs,
statusPrs,
automation,
refreshTaskPR,
onRemovePR,
triggerRef,
}: MultiChipProps) {
const { t } = useTranslation();
const status = aggregateChipStatus(statusPrs ?? prs);
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
useChipPopoverInteractions();
return (
<Popover open={open} onOpenChange={onOpenChange}>
<span
className="inline-flex"
onMouseOver={onTriggerEnter}
onMouseEnter={onTriggerEnter}
onMouseMove={onTriggerEnter}
onPointerOver={onTriggerEnter}
onPointerEnter={onTriggerEnter}
onPointerMove={onTriggerEnter}
onMouseLeave={onTriggerLeave}
onPointerLeave={onTriggerLeave}
onFocus={onTriggerEnter}
onBlur={onTriggerLeave}
>
<PopoverAnchor asChild>
<button ref={ref} type="button" {...multiChipButtonAttrs(prs, status, automation, t)}>
<MultiChipGlyph prs={prs} status={status} automation={automation} />
</button>
</PopoverAnchor>
</span>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className={`w-96 p-2.5 ${PR_CI_DESKTOP_POPOVER_SCROLL_CLASS}`}
onMouseEnter={onContentEnter}
onMouseMove={onContentEnter}
onMouseLeave={onContentLeave}
onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MultiPRCIPopover
prs={prs}
enabled={open}
refreshTaskPR={refreshTaskPR}
onRemovePR={onRemovePR}
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
/>
</PopoverContent>
</Popover>
);
}
function PRStatusChipMultiDrawer({
prs,
statusPrs,
automation,
refreshTaskPR,
onRemovePR,
triggerRef,
}: MultiChipProps) {
const { t } = useTranslation();
const status = aggregateChipStatus(statusPrs ?? prs);
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
{...multiChipButtonAttrs(prs, status, automation, t)}
>
<MultiChipGlyph prs={prs} status={status} automation={automation} />
</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">
{t("github:pullRequestCount", { count: prs.length })}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t("github:pullRequestCiStatusReviewsAnd")}
</DrawerDescription>
<DrawerClose asChild>
<Button
data-testid="pr-status-chip-drawer-close"
variant="ghost"
size="icon-sm"
aria-label={t("github:closePrStatus")}
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>
<MultiPRCIPopover
prs={prs}
enabled={open}
refreshTaskPR={refreshTaskPR}
onRemovePR={onRemovePR}
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
/>
</div>
</DrawerContent>
</Drawer>
);
}
function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
const { t } = useTranslation();
const status = chipStatus(pr);
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
{...chipButtonAttrs(pr, status, automation, t)}
>
<IconChecklist className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<ChipStatusGlyph status={status} />
<AutomationFlagBadges automation={automation} />
</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">
{t("github:pr")}
{pr.pr_number}
</DrawerTitle>
<DrawerDescription className="sr-only">
{t("github:pullRequestCiStatusReviewsAnd")}
</DrawerDescription>
<DrawerClose asChild>
<Button
data-testid="pr-status-chip-drawer-close"
variant="ghost"
size="icon-sm"
aria-label={t("github:closePrStatus")}
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} refreshTaskPR={refreshTaskPR} />
</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 "conflict":
return <IconAlertTriangleFilled className="h-3.5 w-3.5 text-red-500" aria-hidden="true" />;
case "behind":
return <IconAlertTriangleFilled className="h-3.5 w-3.5 text-yellow-500" aria-hidden="true" />;
case "draft":
return <IconPointFilled className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />;
case "blocked":
return (
<IconShield
data-testid="pr-status-glyph-blocked"
className="h-3.5 w-3.5 text-yellow-500"
aria-hidden="true"
/>
);
case "waiting":
return <IconClock className="h-3.5 w-3.5 text-muted-foreground" 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" />;
}
}