598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import {
|
|
IconCheck,
|
|
IconCircleCheck,
|
|
IconCircleDot,
|
|
IconCircleX,
|
|
IconExternalLink,
|
|
IconGitPullRequest,
|
|
IconMessageCircle,
|
|
IconPlus,
|
|
} from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useCommentsStore } from "@/lib/state/slices/comments";
|
|
import type { PRFeedbackComment } from "@/lib/state/slices/comments";
|
|
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
|
import { usePRCIPopover } from "@/hooks/domains/github/use-pr-ci-popover";
|
|
import {
|
|
bucketCheck,
|
|
bucketCheckCounts,
|
|
groupChecksByWorkflow,
|
|
type CheckBucket,
|
|
type WorkflowGroup,
|
|
} from "@/lib/github/check-buckets";
|
|
import type { CheckRun, TaskPR } from "@/lib/types/github";
|
|
import { PRMergeButton } from "./pr-merge-button";
|
|
|
|
type CountsView = {
|
|
passed: number;
|
|
inProgress: number;
|
|
failed: number;
|
|
};
|
|
|
|
const CHECK_GROUP_ORDER: CheckBucket[] = ["passed", "in_progress", "failed"];
|
|
|
|
function countForBucket(counts: CountsView, kind: CheckBucket): number {
|
|
if (kind === "passed") return counts.passed;
|
|
if (kind === "in_progress") return counts.inProgress;
|
|
return counts.failed;
|
|
}
|
|
|
|
export function deriveAggregateCounts(pr: TaskPR): CountsView {
|
|
// Pre-load coarse split from aggregate fields; lazy PRFeedback fetch replaces it.
|
|
const total = Math.max(0, pr.checks_total);
|
|
const passing = Math.min(Math.max(0, pr.checks_passing), total);
|
|
const remaining = Math.max(0, total - passing);
|
|
if (pr.checks_state === "failure") {
|
|
const failed = remaining > 0 ? remaining : 1;
|
|
const passed = total > 0 ? Math.max(0, total - failed) : 0;
|
|
return { passed, failed, inProgress: 0 };
|
|
}
|
|
if (pr.checks_state === "pending") {
|
|
const inProgress = remaining > 0 ? remaining : 1;
|
|
const passed = total > 0 ? Math.max(0, total - inProgress) : 0;
|
|
return { passed, failed: 0, inProgress };
|
|
}
|
|
if (pr.checks_state === "success") {
|
|
return { passed: total, failed: 0, inProgress: 0 };
|
|
}
|
|
return { passed: passing, failed: 0, inProgress: remaining };
|
|
}
|
|
|
|
export function hasNoChecksAtAll(
|
|
pr: TaskPR,
|
|
feedback: { checks?: CheckRun[] } | null,
|
|
isFetching: boolean,
|
|
): boolean {
|
|
return (
|
|
!isFetching &&
|
|
pr.checks_state === "" &&
|
|
pr.checks_total === 0 &&
|
|
(!feedback || (feedback.checks?.length ?? 0) === 0)
|
|
);
|
|
}
|
|
|
|
function PRCIPopoverHeader({ pr }: { pr: TaskPR }) {
|
|
const checksUrl = `${pr.pr_url}/checks`;
|
|
return (
|
|
<div
|
|
data-testid="pr-popover-header"
|
|
className="flex items-center justify-between gap-2 border-b border-border/50 pb-2"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm font-medium">CI status</span>
|
|
<a
|
|
data-testid="pr-popover-external-link"
|
|
href={checksUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
|
aria-label="View all checks on GitHub"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<IconExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
</div>
|
|
<a
|
|
data-testid="pr-popover-pr-link"
|
|
href={pr.pr_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
|
aria-label="View pull request on GitHub"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<IconGitPullRequest className="h-3.5 w-3.5" />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CheckGroupIcon({ kind }: { kind: CheckBucket }) {
|
|
if (kind === "passed") return <IconCircleCheck className="h-3.5 w-3.5 text-emerald-500" />;
|
|
if (kind === "in_progress")
|
|
return <IconCircleDot className="h-3.5 w-3.5 text-yellow-500 animate-pulse" />;
|
|
return <IconCircleX className="h-3.5 w-3.5 text-red-500" />;
|
|
}
|
|
|
|
function bucketLabel(kind: CheckBucket): string {
|
|
if (kind === "passed") return "Passed";
|
|
if (kind === "in_progress") return "In progress";
|
|
return "Failed";
|
|
}
|
|
|
|
function CheckGroupHeader({ kind, count }: { kind: CheckBucket; count: number }) {
|
|
const label = bucketLabel(kind);
|
|
return (
|
|
<div className="flex items-center justify-between gap-2 px-1 py-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<CheckGroupIcon kind={kind} />
|
|
<span className="text-xs font-medium">{label}</span>
|
|
</div>
|
|
<span
|
|
data-testid="pr-check-group-count"
|
|
className="text-xs tabular-nums text-muted-foreground"
|
|
>
|
|
{count}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PRWorkflowRow({
|
|
group,
|
|
onAddAsContext,
|
|
}: {
|
|
group: WorkflowGroup;
|
|
onAddAsContext: ((message: string) => void) | null;
|
|
}) {
|
|
// For an in-progress workflow, "0/1 ran" reads as "nothing finished"
|
|
// and confuses people: did it start? It's clearer to just report how
|
|
// many jobs are running. (Failed workflows go to a different bucket,
|
|
// so an in_progress group never has failed jobs.)
|
|
const badge =
|
|
group.bucket === "in_progress"
|
|
? `${group.inProgress} running`
|
|
: `${group.passed}/${group.total} passed`;
|
|
return (
|
|
<div
|
|
data-testid="pr-workflow-row"
|
|
data-workflow={group.workflow}
|
|
data-bucket={group.bucket}
|
|
className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer"
|
|
onClick={() => {
|
|
if (group.htmlUrl) window.open(group.htmlUrl, "_blank", "noopener,noreferrer");
|
|
}}
|
|
>
|
|
<span className="text-xs font-medium truncate min-w-0 flex-1" title={group.workflow}>
|
|
{group.workflow}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground shrink-0">{badge}</span>
|
|
{group.htmlUrl && (
|
|
<Button
|
|
data-testid="pr-workflow-open"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-5 w-5 p-0 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
window.open(group.htmlUrl, "_blank", "noopener,noreferrer");
|
|
}}
|
|
aria-label={`Open ${group.workflow} on GitHub`}
|
|
>
|
|
<IconExternalLink className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
{group.bucket === "failed" && onAddAsContext && (
|
|
<Button
|
|
data-testid="pr-workflow-add-context"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-5 w-5 p-0 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAddAsContext(buildWorkflowMessage(group));
|
|
}}
|
|
aria-label={`Add ${group.workflow} failures to chat context`}
|
|
>
|
|
<IconPlus className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildWorkflowMessage(group: WorkflowGroup): string {
|
|
const failed = group.jobs.filter((j) => bucketCheck(j) === "failed");
|
|
const lines: string[] = [
|
|
`### Workflow **${group.workflow}** has ${failed.length} failing job${failed.length !== 1 ? "s" : ""}.`,
|
|
"",
|
|
];
|
|
for (const job of failed) {
|
|
lines.push(`- **${job.name}** — ${job.conclusion || job.status}`);
|
|
if (job.output) lines.push(` ${job.output}`);
|
|
if (job.html_url) lines.push(` ${job.html_url}`);
|
|
}
|
|
lines.push("", "Please investigate and fix.");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function PRCheckGroup({
|
|
kind,
|
|
count,
|
|
workflows,
|
|
isLoading,
|
|
onAddAsContext,
|
|
}: {
|
|
kind: CheckBucket;
|
|
count: number;
|
|
workflows?: WorkflowGroup[];
|
|
isLoading: boolean;
|
|
onAddAsContext: ((message: string) => void) | null;
|
|
}) {
|
|
if (count <= 0 && (!workflows || workflows.length === 0)) return null;
|
|
const showRows = kind !== "passed";
|
|
const hasWorkflows = !!workflows && workflows.length > 0;
|
|
return (
|
|
<div data-testid="pr-check-group" data-kind={kind} className="flex flex-col">
|
|
<CheckGroupHeader kind={kind} count={count} />
|
|
{showRows && (
|
|
<div className="flex flex-col pl-5">
|
|
{hasWorkflows &&
|
|
workflows!.map((g) => (
|
|
<PRWorkflowRow
|
|
key={`${g.workflow}-${g.bucket}`}
|
|
group={g}
|
|
onAddAsContext={kind === "failed" ? onAddAsContext : null}
|
|
/>
|
|
))}
|
|
{!hasWorkflows && isLoading
|
|
? Array.from({ length: Math.min(2, count || 1) }).map((_, i) => (
|
|
<div
|
|
key={`skel-${i}`}
|
|
data-testid="pr-workflow-row-skeleton"
|
|
className="h-5 my-0.5 rounded-sm bg-muted animate-pulse"
|
|
/>
|
|
))
|
|
: null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stacked progress bar mirroring TodoIndicator's bar but split into three
|
|
* segments — green (passed), yellow (in_progress), red (failed) — each
|
|
* sized proportionally to its count. The leading "pass rate" label gives
|
|
* the same at-a-glance number TodoIndicator shows ("6/10 (60%)").
|
|
*
|
|
* Skipped/cancelled-but-not-failed jobs aren't counted toward the total
|
|
* (they never enter any bucket), so the bar always reflects the actionable
|
|
* picture: how close are we to "all green".
|
|
*/
|
|
function PRChecksProgressBar({ counts }: { counts: CountsView }) {
|
|
const total = counts.passed + counts.inProgress + counts.failed;
|
|
if (total === 0) return null;
|
|
const pct = (n: number) => (n / total) * 100;
|
|
const passedPct = pct(counts.passed);
|
|
const inProgressPct = pct(counts.inProgress);
|
|
const failedPct = pct(counts.failed);
|
|
const completePct = Math.round(passedPct);
|
|
return (
|
|
<div data-testid="pr-checks-progress" className="flex flex-col gap-1.5 px-1 pt-1 pb-1.5">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="font-medium text-foreground">Pass rate</span>
|
|
<span className="text-muted-foreground tabular-nums">
|
|
{counts.passed}/{total} ({completePct}%)
|
|
</span>
|
|
</div>
|
|
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted/70">
|
|
{passedPct > 0 && (
|
|
<div
|
|
data-segment="passed"
|
|
className="h-full bg-green-500 transition-[width]"
|
|
style={{ width: `${passedPct}%` }}
|
|
/>
|
|
)}
|
|
{inProgressPct > 0 && (
|
|
<div
|
|
data-segment="in_progress"
|
|
className="h-full bg-yellow-500 transition-[width]"
|
|
style={{ width: `${inProgressPct}%` }}
|
|
/>
|
|
)}
|
|
{failedPct > 0 && (
|
|
<div
|
|
data-segment="failed"
|
|
className="h-full bg-red-500 transition-[width]"
|
|
style={{ width: `${failedPct}%` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PRChecksSection({
|
|
pr,
|
|
feedback,
|
|
isFetching,
|
|
onAddAsContext,
|
|
}: {
|
|
pr: TaskPR;
|
|
feedback: { checks?: CheckRun[] } | null;
|
|
isFetching: boolean;
|
|
onAddAsContext: ((message: string) => void) | null;
|
|
}) {
|
|
const aggregateCounts = useMemo(() => deriveAggregateCounts(pr), [pr]);
|
|
|
|
const { precise, byBucket } = useMemo(() => {
|
|
// Treat empty `feedback.checks` the same as "feedback not loaded yet" so
|
|
// we keep showing the aggregate counts. Some mock paths return empty
|
|
// arrays without errors, and we don't want the popover to flash 0/0/0.
|
|
if (!feedback?.checks || feedback.checks.length === 0) {
|
|
return { precise: null as CountsView | null, byBucket: null };
|
|
}
|
|
const counts = bucketCheckCounts(feedback.checks);
|
|
const precise: CountsView = {
|
|
passed: counts.passed,
|
|
inProgress: counts.inProgress,
|
|
failed: counts.failed,
|
|
};
|
|
const groups = groupChecksByWorkflow(feedback.checks);
|
|
const byBucket: Record<CheckBucket, WorkflowGroup[]> = {
|
|
passed: groups.filter((g) => g.bucket === "passed"),
|
|
in_progress: groups.filter((g) => g.bucket === "in_progress"),
|
|
failed: groups.filter((g) => g.bucket === "failed"),
|
|
};
|
|
return { precise, byBucket };
|
|
}, [feedback]);
|
|
|
|
const counts = precise ?? aggregateCounts;
|
|
// "No checks at all": the lazy fetch has settled (not isFetching) and there
|
|
// are no checks anywhere — neither in PRFeedback nor in the aggregate.
|
|
if (hasNoChecksAtAll(pr, feedback, isFetching)) {
|
|
return (
|
|
<div data-testid="pr-checks-section" className="flex flex-col">
|
|
<div data-testid="pr-checks-empty" className="px-1 py-2 text-xs text-muted-foreground">
|
|
No checks have started
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div data-testid="pr-checks-section" className="flex flex-col gap-1">
|
|
<PRChecksProgressBar counts={counts} />
|
|
{CHECK_GROUP_ORDER.map((kind) => {
|
|
const value = countForBucket(counts, kind);
|
|
return (
|
|
<PRCheckGroup
|
|
key={kind}
|
|
kind={kind}
|
|
count={value}
|
|
workflows={byBucket?.[kind]}
|
|
isLoading={isFetching && !byBucket}
|
|
onAddAsContext={kind === "failed" ? onAddAsContext : null}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PRReviewRow({ pr }: { pr: TaskPR }) {
|
|
const required = pr.required_reviews ?? null;
|
|
const approved = pr.review_count;
|
|
const requested = pr.pending_review_count;
|
|
|
|
let label: string;
|
|
let icon: ReactNode;
|
|
if (pr.review_state === "approved") {
|
|
icon = <IconCheck className="h-3.5 w-3.5 text-emerald-500" />;
|
|
label = "Approved";
|
|
} else if (pr.review_state === "changes_requested") {
|
|
icon = <IconCircleX className="h-3.5 w-3.5 text-red-500" />;
|
|
label = "Changes requested";
|
|
} else {
|
|
// Default branch covers "no reviews yet", "review pending", and any
|
|
// unknown state. Always rendered (no early-return) so a fresh PR
|
|
// still surfaces its review status.
|
|
icon = <IconCircleDot className="h-3.5 w-3.5 text-muted-foreground" />;
|
|
label = "Awaiting review";
|
|
}
|
|
// Counts live on the right edge, mirroring the check-group rows.
|
|
// Use "K / M" when a required-minimum is known, otherwise just the
|
|
// approved count. The "(K requested)" suffix slides in when reviewers
|
|
// have been pinged but haven't submitted a review.
|
|
let countText: string;
|
|
if (required != null) countText = `${approved} / ${required}`;
|
|
else countText = `${approved}`;
|
|
if (requested > 0) countText += ` · ${requested} requested`;
|
|
return (
|
|
<div
|
|
data-testid="pr-review-row"
|
|
className="flex items-center justify-between gap-2 px-1 py-1 text-xs"
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{icon}
|
|
<span className="truncate">{label}</span>
|
|
</div>
|
|
<span className="shrink-0 text-muted-foreground tabular-nums">{countText}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PRCommentsRow({ pr }: { pr: TaskPR }) {
|
|
if (!pr.unresolved_review_threads || pr.unresolved_review_threads <= 0) return null;
|
|
return (
|
|
<div data-testid="pr-comments-row" className="flex items-center gap-1.5 px-1 py-1 text-xs">
|
|
<IconMessageCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span>
|
|
{pr.unresolved_review_threads} unresolved comment
|
|
{pr.unresolved_review_threads === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function elapsedLabel(elapsed: number | null): string {
|
|
if (elapsed == null) return "";
|
|
if (elapsed === 0) return "updated just now";
|
|
return `updated ${formatElapsedShort(elapsed)} ago`;
|
|
}
|
|
|
|
function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
|
|
// Capture a "now" snapshot only inside the setInterval callback (an event
|
|
// handler — no impurity in render, no setState during commit). When no
|
|
// tick has fired yet, fall back to lastUpdatedAt so the rendered elapsed
|
|
// is 0 ("just now").
|
|
const [now, setNow] = useState<number | null>(null);
|
|
useEffect(() => {
|
|
if (lastUpdatedAt == null) return;
|
|
const id = setInterval(() => setNow(Date.now()), 10_000);
|
|
return () => clearInterval(id);
|
|
}, [lastUpdatedAt]);
|
|
const elapsed =
|
|
lastUpdatedAt == null
|
|
? null
|
|
: Math.max(0, Math.floor(((now ?? lastUpdatedAt) - lastUpdatedAt) / 1000));
|
|
if (lastUpdatedAt == null) return null;
|
|
return (
|
|
<div
|
|
data-testid="pr-popover-footer"
|
|
className="flex items-center justify-end border-t border-border/50 pt-1.5"
|
|
>
|
|
<span
|
|
data-testid="pr-popover-updated-at"
|
|
className="text-[10px] text-muted-foreground tabular-nums"
|
|
>
|
|
{elapsedLabel(elapsed)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatElapsedShort(seconds: number): string {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
return `${hours}h`;
|
|
}
|
|
|
|
function ReconnectGitHubBlock() {
|
|
return (
|
|
<div
|
|
data-testid="pr-popover-auth-error"
|
|
className="flex flex-col items-start gap-1 px-1 py-2 text-xs"
|
|
>
|
|
<span className="text-foreground">GitHub authentication lost.</span>
|
|
<a
|
|
data-testid="pr-popover-reconnect-link"
|
|
href="/settings#github"
|
|
className="cursor-pointer text-primary hover:underline"
|
|
>
|
|
Reconnect GitHub
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PRCIPopover({
|
|
pr,
|
|
enabled,
|
|
onOpenDetailPanel,
|
|
}: {
|
|
pr: TaskPR;
|
|
enabled: boolean;
|
|
onOpenDetailPanel?: () => void;
|
|
}) {
|
|
const ghStatus = useAppStore((s) => s.githubStatus.status);
|
|
const authLost = ghStatus !== null && !ghStatus.authenticated;
|
|
// Trigger an initial status load from the same hook the rest of the app uses.
|
|
useGitHubStatus();
|
|
const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(pr, enabled && !authLost);
|
|
const onAddAsContext = useAddCheckToContext(pr);
|
|
|
|
return (
|
|
<div
|
|
data-testid="pr-topbar-popover-inner"
|
|
className="flex flex-col gap-2"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<PRCIPopoverHeader pr={pr} />
|
|
{authLost ? (
|
|
<ReconnectGitHubBlock />
|
|
) : (
|
|
<>
|
|
<PRChecksSection
|
|
pr={pr}
|
|
feedback={feedback}
|
|
isFetching={isFetching}
|
|
onAddAsContext={onAddAsContext}
|
|
/>
|
|
<div className="flex flex-col gap-0">
|
|
<PRReviewRow pr={pr} />
|
|
<PRCommentsRow pr={pr} />
|
|
</div>
|
|
<PRMergeButton taskPR={pr} onMerged={refetch} compact />
|
|
</>
|
|
)}
|
|
{onOpenDetailPanel && (
|
|
<Button
|
|
data-testid="pr-popover-open-detail"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="cursor-pointer justify-start gap-1.5 px-2"
|
|
onClick={onOpenDetailPanel}
|
|
>
|
|
<IconGitPullRequest className="h-3.5 w-3.5" />
|
|
<span>Open PR details</span>
|
|
</Button>
|
|
)}
|
|
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Add-to-context wiring (mirrors pr-detail-panel.tsx for failed checks) ---
|
|
function useAddCheckToContext(pr: TaskPR): ((message: string) => void) | null {
|
|
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
|
|
const addComment = useCommentsStore((s) => s.addComment);
|
|
const { toast } = useToast();
|
|
const prNumber = pr.pr_number;
|
|
// Always call useCallback (rules-of-hooks) before bailing out, so the
|
|
// returned callback identity is stable across renders unless its inputs
|
|
// change. Without memoization the popover would create a new function
|
|
// every parent render, defeating PRWorkflowRow's cheap reference equality.
|
|
const handler = useCallback(
|
|
(message: string) => {
|
|
if (!sessionId) return;
|
|
const comment: PRFeedbackComment = {
|
|
id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
sessionId,
|
|
text: message,
|
|
createdAt: new Date().toISOString(),
|
|
status: "pending",
|
|
source: "pr-feedback",
|
|
prNumber,
|
|
feedbackType: "check",
|
|
content: message,
|
|
};
|
|
addComment(comment);
|
|
toast({ description: "Added to chat context" });
|
|
},
|
|
[sessionId, prNumber, addComment, toast],
|
|
);
|
|
return sessionId ? handler : null;
|
|
}
|
|
|
|
// Multi-PR aggregate popover deferred — multi-PR tasks currently keep the
|
|
// existing DropdownMenu trigger (no hover popover). The aggregate component
|
|
// will land alongside that hover variant when it ships, see follow-up.
|