feat(github): show an Updating… indicator in the PR status popover (#2402)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-08 00:07:08 +02:00
committed by GitHub
parent 8138a4c1af
commit 9a1ab20188
17 changed files with 671 additions and 24 deletions
@@ -122,7 +122,7 @@ export function MultiPRCIPopover({
prs: TaskPR[];
enabled: boolean;
onOpenDetailPanel?: (pr: TaskPR) => void;
refreshTaskPR?: () => void;
refreshTaskPR?: () => void | Promise<void>;
onRemovePR?: (pr: TaskPR) => Promise<void>;
onCollapseFocus?: (remainingPR: TaskPR) => void;
}) {
@@ -31,6 +31,7 @@ vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
usePRCIPopover: () => ({
feedback: null,
isFetching: false,
isRefreshing: false,
lastUpdatedAt: null,
refetch: vi.fn(),
}),
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import type { TaskPR } from "@/lib/types/github";
const popoverState = vi.hoisted(() => ({
isFetching: false,
isRefreshing: false,
lastUpdatedAt: null as number | null,
}));
vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
usePRFeedbackBackgroundSync: vi.fn(),
usePRCIPopover: () => ({
feedback: null,
isFetching: popoverState.isFetching,
isRefreshing: popoverState.isRefreshing,
lastUpdatedAt: popoverState.lastUpdatedAt,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
useTaskCIAutomationOptions: () => ({
options: null,
loading: false,
saving: false,
error: null,
refresh: vi.fn(),
update: vi.fn(),
resetPrompt: vi.fn(),
}),
}));
import { PRCIPopover } from "./pr-ci-popover";
const UPDATING_LABEL = "Updating…";
function makePR(): TaskPR {
return {
id: "id",
task_id: "task-1",
owner: "acme",
repo: "app",
pr_number: 7,
pr_url: "https://github.com/acme/app/pull/7",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "",
checks_state: "success",
mergeable_state: "clean",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 1,
checks_passing: 1,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
};
}
function renderPopover() {
return render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<PRCIPopover pr={makePR()} enabled={true} />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
}
describe("PR popover footer refresh indicator", () => {
beforeEach(() => {
popoverState.isFetching = false;
popoverState.isRefreshing = false;
popoverState.lastUpdatedAt = null;
});
afterEach(() => {
cleanup();
});
it("shows a spinner and 'Updating…' instead of a freshness claim while refreshing", () => {
popoverState.isRefreshing = true;
popoverState.lastUpdatedAt = Date.now();
renderPopover();
const updating = screen.getByTestId("pr-popover-updating");
expect(updating.textContent).toContain(UPDATING_LABEL);
expect(updating.querySelector(".animate-spin")).not.toBeNull();
expect(screen.queryByTestId("pr-popover-updated-at")).toBeNull();
});
it("announces the refresh to assistive tech", () => {
popoverState.isRefreshing = true;
popoverState.lastUpdatedAt = Date.now();
renderPopover();
expect(screen.getByRole("status").textContent).toContain(UPDATING_LABEL);
});
it("shows the elapsed label once the refresh settles", () => {
popoverState.isRefreshing = false;
popoverState.lastUpdatedAt = Date.now();
renderPopover();
expect(screen.getByTestId("pr-popover-updated-at").textContent).toBe("updated just now");
expect(screen.queryByTestId("pr-popover-updating")).toBeNull();
});
it("shows the indicator on a first open, when there is no cached timestamp yet", () => {
popoverState.isRefreshing = true;
popoverState.lastUpdatedAt = null;
renderPopover();
expect(screen.getByTestId("pr-popover-updating").textContent).toContain(UPDATING_LABEL);
});
it("renders no footer at all when there is nothing to report", () => {
popoverState.isRefreshing = false;
popoverState.lastUpdatedAt = null;
renderPopover();
expect(screen.queryByTestId("pr-popover-footer")).toBeNull();
});
});
+31 -11
View File
@@ -8,6 +8,7 @@ import {
IconCircleX,
IconExternalLink,
IconGitPullRequest,
IconLoader2,
IconMessageCircle,
IconPlus,
} from "@tabler/icons-react";
@@ -493,7 +494,13 @@ function elapsedLabel(
return t("github:updatedAgo", { elapsed: formatElapsedShort(elapsed) });
}
function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
function PRPopoverFooter({
lastUpdatedAt,
isRefreshing,
}: {
lastUpdatedAt: number | null;
isRefreshing: boolean;
}) {
const { t } = useTranslation();
// Capture a "now" snapshot only inside the setInterval callback (an event
// handler — no impurity in render, no setState during commit). When no
@@ -509,18 +516,31 @@ function PRPopoverFooter({ lastUpdatedAt }: { lastUpdatedAt: number | null }) {
lastUpdatedAt == null
? null
: Math.max(0, Math.floor(((now ?? lastUpdatedAt) - lastUpdatedAt) / 1000));
if (lastUpdatedAt == null) return null;
// The first open of a PR has no cache entry yet — that's the slowest fetch of
// all, so the footer has to exist for the spinner even with nothing to date.
if (lastUpdatedAt == null && !isRefreshing) 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, t)}
</span>
{isRefreshing ? (
<span
data-testid="pr-popover-updating"
role="status"
className="flex items-center gap-1.5 text-[10px] text-muted-foreground"
>
<IconLoader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
{t("github:updatingStatus")}
</span>
) : (
<span
data-testid="pr-popover-updated-at"
className="text-[10px] text-muted-foreground tabular-nums"
>
{elapsedLabel(elapsed, t)}
</span>
)}
</div>
);
}
@@ -561,12 +581,12 @@ export function PRCIPopover({
pr: TaskPR;
enabled: boolean;
onOpenDetailPanel?: () => void;
refreshTaskPR?: () => void;
refreshTaskPR?: () => void | Promise<void>;
}) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const { status: ghStatus } = useGitHubStatus(workspaceId);
const authLost = ghStatus !== null && !ghStatus.authenticated;
const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(
const { feedback, isFetching, isRefreshing, lastUpdatedAt, refetch } = usePRCIPopover(
workspaceId,
pr,
enabled && !authLost,
@@ -600,7 +620,7 @@ export function PRCIPopover({
<PRMergeButton taskPR={pr} onMerged={refetch} compact />
</>
)}
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} />
<PRPopoverFooter lastUpdatedAt={lastUpdatedAt} isRefreshing={isRefreshing} />
</div>
);
}
@@ -68,14 +68,14 @@ type TriggerRef = { current: HTMLButtonElement | null };
type SingleChipProps = {
pr: TaskPR;
automation: AutomationFlags;
refreshTaskPR: () => void;
refreshTaskPR: () => void | Promise<void>;
triggerRef?: TriggerRef;
};
type MultiChipProps = {
prs: TaskPR[];
statusPrs?: TaskPR[];
automation: AutomationFlags;
refreshTaskPR: () => void;
refreshTaskPR: () => void | Promise<void>;
onRemovePR?: (pr: TaskPR) => Promise<void>;
triggerRef?: TriggerRef;
};
@@ -145,7 +145,7 @@ function PRSingleButton({
triggerRef,
}: {
pr: TaskPR;
refreshTaskPR: () => void;
refreshTaskPR: () => void | Promise<void>;
triggerRef?: TriggerRef;
}) {
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
@@ -229,7 +229,7 @@ function PRMultiButton({
triggerRef,
}: {
prs: TaskPR[];
refreshTaskPR: () => void;
refreshTaskPR: () => void | Promise<void>;
onRemovePR: (associationId: string) => Promise<void>;
triggerRef?: TriggerRef;
}) {