feat: unlink task pull requests (#2114)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user