feat: unlink task pull requests (#2114)

This commit is contained in:
Carlos Florêncio
2026-08-02 17:00:32 +01:00
committed by GitHub
parent 554c33f24d
commit a6326fdc5d
38 changed files with 1808 additions and 109 deletions
@@ -1,13 +1,14 @@
"use client";
import { useId, useState, type KeyboardEvent } from "react";
import { IconGitPullRequest } from "@tabler/icons-react";
import { IconGitPullRequest, IconLoader2, IconX } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { PRCIPopover } from "@/components/github/pr-ci-popover";
import { getPRStatusColor, pickDefaultPR } from "@/components/github/pr-task-icon";
import { prIdentitySlug } from "@/components/github/pr-utils";
import { usePRFeedbackBackgroundSync } from "@/hooks/domains/github/use-pr-ci-popover";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import type { TaskPR } from "@/lib/types/github";
/**
@@ -39,38 +40,66 @@ function PRTab({
panelId,
active,
onSelect,
canRemove,
removing,
onRemove,
}: {
pr: TaskPR;
uid: string;
panelId: string;
active: boolean;
onSelect: (pr: TaskPR) => void;
canRemove: boolean;
removing: boolean;
onRemove?: (pr: TaskPR) => void;
}) {
return (
<button
type="button"
role="tab"
id={prTabDomId(uid, pr)}
// Roving tabindex (WAI-ARIA tabs pattern): only the active tab is in the
// page tab order; ArrowLeft/ArrowRight move between tabs.
tabIndex={active ? 0 : -1}
aria-selected={active}
aria-controls={panelId}
data-testid={`pr-popover-tab-${prIdentitySlug(pr)}`}
data-active={active ? "true" : "false"}
onClick={() => onSelect(pr)}
className={cn(
"flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-transparent px-2 py-1 text-xs whitespace-nowrap transition-colors",
active
? "border-primary/50 bg-card text-foreground"
: "text-muted-foreground hover:bg-muted",
<div className="flex shrink-0 items-center rounded-md border border-transparent">
<button
type="button"
role="tab"
id={prTabDomId(uid, pr)}
// Roving tabindex (WAI-ARIA tabs pattern): only the active tab is in the
// page tab order; ArrowLeft/ArrowRight move between tabs.
tabIndex={active ? 0 : -1}
aria-selected={active}
aria-controls={panelId}
data-testid={`pr-popover-tab-${prIdentitySlug(pr)}`}
data-active={active ? "true" : "false"}
onClick={() => onSelect(pr)}
className={cn(
"flex min-h-6 cursor-pointer items-center gap-1 rounded-md border border-transparent px-2 py-1 text-xs whitespace-nowrap transition-colors [@media(pointer:coarse)]:min-h-11",
active
? "border-primary/50 bg-card text-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
<IconGitPullRequest className={cn("h-3.5 w-3.5", getPRStatusColor(pr))} />
<span className="font-medium">
{pr.repo} #{pr.pr_number}
</span>
</button>
{canRemove && (
<button
type="button"
data-testid={`pr-popover-remove-${prIdentitySlug(pr)}`}
aria-label={`Remove ${pr.repo} #${pr.pr_number} from task`}
disabled={removing}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemove?.(pr);
}}
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-60 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
>
{removing ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<IconX className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
)}
>
<IconGitPullRequest className={cn("h-3.5 w-3.5", getPRStatusColor(pr))} />
<span className="font-medium">
{pr.repo} #{pr.pr_number}
</span>
</button>
</div>
);
}
@@ -85,11 +114,15 @@ export function MultiPRCIPopover({
enabled,
onOpenDetailPanel,
refreshTaskPR,
onRemovePR,
onCollapseFocus,
}: {
prs: TaskPR[];
enabled: boolean;
onOpenDetailPanel?: (pr: TaskPR) => void;
refreshTaskPR?: () => void;
onRemovePR?: (pr: TaskPR) => Promise<void>;
onCollapseFocus?: (remainingPR: TaskPR) => void;
}) {
// `overrideId` is only set when the user activates a tab. The displayed PR is
// derived: honour the override while it still exists, otherwise fall back to
@@ -101,9 +134,41 @@ export function MultiPRCIPopover({
// always references an element that exists in the DOM.
const uid = useId();
const panelId = `${uid}-pr-ci-tabpanel`;
const [removingId, setRemovingId] = useState<string | null>(null);
const { toast } = useToast();
const selected = prs.find((p) => p.id === overrideId) ?? pickDefaultPR(prs);
if (!selected) return null;
const removePR = async (pr: TaskPR) => {
if (!onRemovePR || removingId) return;
setRemovingId(pr.id);
try {
await onRemovePR(pr);
if (selected.id === pr.id) {
const removedIndex = prs.findIndex((candidate) => candidate.id === pr.id);
const next = prs[removedIndex + 1] ?? prs[removedIndex - 1];
if (next && prs.length > 2) {
setOverrideId(next.id);
// Keep keyboard focus on the deterministic adjacent tab while the
// multi-PR surface remains mounted.
document.getElementById(prTabDomId(uid, next))?.focus();
} else if (next) {
// The parent collapses to its single-PR surface after a two-to-one
// removal, so let it restore focus to the surviving trigger.
onCollapseFocus?.(next);
}
}
} catch (error) {
toast({
title: "Failed to unlink pull request",
description: error instanceof Error ? error.message : "The pull request is still linked.",
variant: "error",
});
} finally {
setRemovingId(null);
}
};
// ArrowLeft/ArrowRight move selection (and focus) within the tablist,
// wrapping at the ends — selection follows focus, per the ARIA tabs pattern.
const onTablistKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -138,6 +203,9 @@ export function MultiPRCIPopover({
panelId={panelId}
active={pr.id === selected.id}
onSelect={(p) => setOverrideId(p.id)}
canRemove={prs.length > 1 && Boolean(onRemovePR)}
removing={removingId === pr.id}
onRemove={(p) => void removePR(p)}
/>
))}
</div>
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
@@ -54,6 +55,8 @@ const MERGED_PROMPT_LABEL = "PR merged";
const CLOSED_PROMPT_LABEL = "PR closed without merging";
const REVIEW_REQUEST_PROMPT_LABEL = "Your review is requested";
const REVIEW_FOLLOW_UP_TRIGGER = "ci-review-follow-up-trigger";
const REMOVE_FIRST_PR_LABEL = "Remove r #1 from task";
const BACKEND_UNAVAILABLE = "backend unavailable";
function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
@@ -290,6 +293,116 @@ describe("PRCIPopover automation status", () => {
});
});
describe("MultiPRCIPopover unlink", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("unlinks one multi-PR tab while keeping its sibling visible", async () => {
const onCollapseFocus = vi.fn();
function RemovablePopover() {
const [prs, setPrs] = useState([
makePR({ id: "a", pr_number: 1, pr_title: "First PR" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR", checks_state: "success" }),
]);
return (
<MultiPRCIPopover
prs={prs}
enabled={true}
onRemovePR={async (pr) =>
setPrs((current) => current.filter((item) => item.id !== pr.id))
}
onCollapseFocus={onCollapseFocus}
/>
);
}
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<RemovablePopover />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: REMOVE_FIRST_PR_LABEL })).toBeNull(),
);
expect(screen.getByRole("tab", { name: "r #2" })).not.toBeNull();
expect(onCollapseFocus).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
});
it("keeps a failed unlink tab and reports the error", async () => {
const onRemovePR = vi.fn().mockRejectedValue(new Error(BACKEND_UNAVAILABLE));
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<MultiPRCIPopover
prs={[makePR({ id: "a", pr_number: 1 }), makePR({ id: "b", pr_number: 2 })]}
enabled={true}
onRemovePR={onRemovePR}
/>
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL }));
await waitFor(() =>
expect(screen.getByTestId("toast-message").textContent).toContain(BACKEND_UNAVAILABLE),
);
expect(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL })).not.toBeNull();
expect(onRemovePR).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
});
it("focuses the next adjacent tab when a selected tab is removed from a larger set", async () => {
function RemovablePopover() {
const [prs, setPrs] = useState([
makePR({ id: "a", pr_number: 1, pr_title: "First PR", checks_state: "success" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR" }),
makePR({ id: "c", pr_number: 3, pr_title: "Third PR", checks_state: "success" }),
]);
return (
<MultiPRCIPopover
prs={prs}
enabled={true}
onRemovePR={async (pr) =>
setPrs((current) => current.filter((item) => item.id !== pr.id))
}
/>
);
}
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<RemovablePopover />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
expect(screen.getByRole("tab", { name: "r #2" }).getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Remove r #2 from task" }));
await waitFor(() => expect(screen.queryByRole("tab", { name: "r #2" })).toBeNull());
const nextTab = screen.getByRole("tab", { name: "r #3" });
expect(nextTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(nextTab);
});
});
describe("PRCIPopover task prompts", () => {
beforeEach(() => {
resetHookMocks();
@@ -338,10 +451,10 @@ describe("PRCIPopover task prompts", () => {
});
it("offers retry after CI automation options fail to load", () => {
hookMocks.error = "backend unavailable";
hookMocks.error = BACKEND_UNAVAILABLE;
renderPopover();
expect(screen.getByText("backend unavailable")).not.toBeNull();
expect(screen.getByText(BACKEND_UNAVAILABLE)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(hookMocks.refreshMock).toHaveBeenCalledTimes(1);
@@ -129,6 +129,7 @@ afterEach(() => {
const CHIP_TESTID = "pr-status-chip";
const ATTR_PR_NUMBER = "data-pr-number";
const ATTR_PR_COUNT = "data-pr-count";
const ATTR_STATUS = "data-status";
const ATTR_READY_TO_MERGE = "data-pr-ready-to-merge";
const DRAWER_SELECTOR = "[data-testid='pr-status-chip-drawer']";
@@ -636,8 +637,7 @@ describe("PRStatusChip — multiple PRs", () => {
it("renders one aggregate chip with a PR count and worst-of status", () => {
renderWithStore(multiState(TWO_OPEN), <PRStatusChip taskId="task-1" />);
const chip = screen.getByTestId(CHIP_TESTID);
expect(chip.getAttribute("data-pr-count")).toBe("2");
// PR #2 is failing, so the aggregate glyph is red.
expect(chip.getAttribute(ATTR_PR_COUNT)).toBe("2");
expect(chip.getAttribute(ATTR_STATUS)).toBe("failed");
});
@@ -646,21 +646,14 @@ describe("PRStatusChip — multiple PRs", () => {
await expectDesktopHoverPopoverConstrained();
});
it("stays visible while at least one PR is still open", () => {
it("keeps terminal siblings in the multi-PR unlink surface", () => {
renderWithStore(
multiState([makePR({ id: "a", state: "merged" }), makePR({ id: "b", pr_number: 2 })]),
<PRStatusChip taskId="task-1" />,
);
// Only one PR is open, so it renders as the single-PR chip (its number).
expect(screen.getByTestId(CHIP_TESTID).getAttribute(ATTR_PR_NUMBER)).toBe("2");
});
it("returns null only when every PR is terminal", () => {
renderWithStore(
multiState([makePR({ id: "a", state: "merged" }), makePR({ id: "b", state: "closed" })]),
<PRStatusChip taskId="task-1" />,
);
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
const chip = screen.getByTestId(CHIP_TESTID);
expect(chip.getAttribute(ATTR_PR_COUNT)).toBe("2");
expect(chip.getAttribute(ATTR_STATUS)).toBe("passed");
});
describe("mobile drawer", () => {
@@ -676,8 +669,6 @@ describe("PRStatusChip — multiple PRs", () => {
});
expect(document.querySelector(DRAWER_SELECTOR)).not.toBeNull();
expect(document.querySelector("[data-testid='pr-multi-popover']")).not.toBeNull();
// One tab per PR (testid is owner+repo-scoped so same-number PRs on
// different repos, or repos sharing a name across owners, stay unique).
expect(document.querySelector("[data-testid='pr-popover-tab-acme-demo-1']")).not.toBeNull();
expect(document.querySelector("[data-testid='pr-popover-tab-acme-demo-2']")).not.toBeNull();
});
+70 -27
View File
@@ -46,8 +46,8 @@ import type { TaskCIAutomationOptions, 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.
// 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"
@@ -63,17 +63,27 @@ type AutomationFlags = {
autoMerge: boolean;
autoFixRound: AutoFixRoundInfo | null;
};
type TriggerRef = { current: HTMLButtonElement | null };
type SingleChipProps = {
pr: TaskPR;
automation: AutomationFlags;
refreshTaskPR: () => void;
triggerRef?: TriggerRef;
};
type MultiChipProps = {
prs: TaskPR[];
statusPrs?: TaskPR[];
automation: AutomationFlags;
refreshTaskPR: () => 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
@@ -132,8 +142,9 @@ const CHIP_BUTTON_CLASS =
* 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<HTMLButtonElement>(null);
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)) {
@@ -171,42 +182,46 @@ function useChipPopoverInteractions() {
* 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.
* 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 } = useTaskPR(taskId);
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).
// 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")
: [];
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 (openPRs.length === 0) return null;
if (openPRs.length === 1)
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={openPRs}
prs={allPRs}
statusPrs={openPRs}
automation={automationForPRs(automationOptions, openPRs)}
refreshTaskPR={refresh}
onRemovePR={(pr) => unlink(pr.id)}
triggerRef={triggerRef}
/>
);
}
@@ -328,9 +343,9 @@ function PRStatusChipInner(props: SingleChipProps) {
return <PRStatusChipHoverCard {...props} />;
}
function PRStatusChipHoverCard({ pr, automation, refreshTaskPR }: SingleChipProps) {
function PRStatusChipHoverCard({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
const status = chipStatus(pr);
const { ref, onPointerDownOutside } = useChipTriggerGuard();
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
useChipPopoverInteractions();
return (
@@ -420,9 +435,16 @@ function MultiChipGlyph({
);
}
function PRStatusChipMultiHoverCard({ prs, automation, refreshTaskPR }: MultiChipProps) {
const status = aggregateChipStatus(prs);
const { ref, onPointerDownOutside } = useChipTriggerGuard();
function PRStatusChipMultiHoverCard({
prs,
statusPrs,
automation,
refreshTaskPR,
onRemovePR,
triggerRef,
}: MultiChipProps) {
const status = aggregateChipStatus(statusPrs ?? prs);
const { ref, onPointerDownOutside } = useChipTriggerGuard(triggerRef);
const { open, onOpenChange, onTriggerEnter, onTriggerLeave, onContentEnter, onContentLeave } =
useChipPopoverInteractions();
return (
@@ -457,18 +479,32 @@ function PRStatusChipMultiHoverCard({ prs, automation, refreshTaskPR }: MultiChi
onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<MultiPRCIPopover prs={prs} enabled={open} refreshTaskPR={refreshTaskPR} />
<MultiPRCIPopover
prs={prs}
enabled={open}
refreshTaskPR={refreshTaskPR}
onRemovePR={onRemovePR}
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
/>
</PopoverContent>
</Popover>
);
}
function PRStatusChipMultiDrawer({ prs, automation, refreshTaskPR }: MultiChipProps) {
const status = aggregateChipStatus(prs);
function PRStatusChipMultiDrawer({
prs,
statusPrs,
automation,
refreshTaskPR,
onRemovePR,
triggerRef,
}: MultiChipProps) {
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}
@@ -496,19 +532,26 @@ function PRStatusChipMultiDrawer({ prs, automation, refreshTaskPR }: MultiChipPr
</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} />
<MultiPRCIPopover
prs={prs}
enabled={open}
refreshTaskPR={refreshTaskPR}
onRemovePR={onRemovePR}
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
/>
</div>
</DrawerContent>
</Drawer>
);
}
function PRStatusChipDrawer({ pr, automation, refreshTaskPR }: SingleChipProps) {
function PRStatusChipDrawer({ pr, automation, refreshTaskPR, triggerRef }: SingleChipProps) {
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}
@@ -1,6 +1,6 @@
"use client";
import { memo, useState, type ReactNode } from "react";
import { memo, useRef, useState, type ReactNode } from "react";
import {
IconGitPullRequest,
IconCheck,
@@ -42,6 +42,12 @@ import type { TaskPR } from "@/lib/types/github";
const POPOVER_OPEN_DELAY_MS = 150;
const POPOVER_CLOSE_DELAY_MS = 150;
type TriggerRef = { current: HTMLButtonElement | null };
function focusAfterCollapse(triggerRef?: TriggerRef) {
if (!triggerRef) return;
setTimeout(() => triggerRef.current?.focus(), 0);
}
// Badge for the hard merge blockers that must beat ready/awaiting-review:
// conflicts (red) and behind-base (amber). Mirrors openMergeBlockerColor so
@@ -100,11 +106,15 @@ export const PRTopbarButton = memo(function PRTopbarButton() {
// useTaskPR fetches if not in store and returns the full per-task list so
// multi-repo tasks can surface every PR (one button for single-repo, a
// dropdown summary for 2+ so the topbar doesn't blow up horizontally).
const { prs, refresh } = useTaskPR(activeTaskId);
const { prs, refresh, unlink } = useTaskPR(activeTaskId);
const triggerRef = useRef<HTMLButtonElement>(null);
if (prs.length === 0) return null;
if (prs.length === 1) return <PRSingleButton pr={prs[0]} refreshTaskPR={refresh} />;
return <PRMultiButton prs={prs} refreshTaskPR={refresh} />;
if (prs.length === 1)
return <PRSingleButton pr={prs[0]} refreshTaskPR={refresh} triggerRef={triggerRef} />;
return (
<PRMultiButton prs={prs} refreshTaskPR={refresh} onRemovePR={unlink} triggerRef={triggerRef} />
);
});
/**
@@ -128,7 +138,15 @@ function usePopoverInteractions() {
return { usesTouchDrawer, ...hover };
}
function PRSingleButton({ pr, refreshTaskPR }: { pr: TaskPR; refreshTaskPR: () => void }) {
function PRSingleButton({
pr,
refreshTaskPR,
triggerRef,
}: {
pr: TaskPR;
refreshTaskPR: () => void;
triggerRef?: TriggerRef;
}) {
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
const tooltip = `${pr.owner}/${pr.repo} #${pr.pr_number} — ${pr.pr_title}`;
const {
@@ -146,6 +164,7 @@ function PRSingleButton({ pr, refreshTaskPR }: { pr: TaskPR; refreshTaskPR: () =
const button = (
<Button
ref={triggerRef}
data-testid="pr-topbar-button"
data-pr-number={pr.pr_number}
data-pr-state={pr.state}
@@ -202,7 +221,17 @@ function PRSingleButton({ pr, refreshTaskPR }: { pr: TaskPR; refreshTaskPR: () =
);
}
function PRMultiButton({ prs, refreshTaskPR }: { prs: TaskPR[]; refreshTaskPR: () => void }) {
function PRMultiButton({
prs,
refreshTaskPR,
onRemovePR,
triggerRef,
}: {
prs: TaskPR[];
refreshTaskPR: () => void;
onRemovePR: (associationId: string) => Promise<void>;
triggerRef?: TriggerRef;
}) {
// Click still drives the dropdown (the explicit "jump to this PR's panel"
// affordance, and the only interaction on touch). Hover adds the aggregate
// CI popover with a tab per PR — desktop only, suppressed on touch where
@@ -229,6 +258,7 @@ function PRMultiButton({ prs, refreshTaskPR }: { prs: TaskPR[]; refreshTaskPR: (
const triggerButton = (
<DropdownMenuTrigger asChild>
<Button
ref={triggerRef}
data-testid="pr-topbar-button"
data-pr-count={prs.length}
size="sm"
@@ -288,6 +318,8 @@ function PRMultiButton({ prs, refreshTaskPR }: { prs: TaskPR[]; refreshTaskPR: (
prs={prs}
enabled={open}
refreshTaskPR={refreshTaskPR}
onRemovePR={(pr) => onRemovePR(pr.id)}
onCollapseFocus={() => focusAfterCollapse(triggerRef)}
onOpenDetailPanel={(pr) => {
addPRPanel(prTaskKey(pr));
onOpenChange(false);