diff --git a/src/components/Dropdown/tokens.ts b/src/components/Dropdown/tokens.ts
index 387c8dd1bb..26c4884984 100644
--- a/src/components/Dropdown/tokens.ts
+++ b/src/components/Dropdown/tokens.ts
@@ -174,6 +174,22 @@ export const DROPDOWN_SEARCH = {
// Composite Class Strings (for easy use)
// ==============================================
+/**
+ * Sticky bordered header row above a panel's scrollable list. Shared by the
+ * search header and by header rows that carry a title plus actions instead.
+ */
+const PANEL_HEADER_ROW = [
+ "flex",
+ "shrink-0",
+ "items-center",
+ DROPDOWN_ITEM.gapClass,
+ "px-3",
+ "py-1.5",
+ "border-b",
+ "border-solid",
+ "border-border-2",
+].join(" ");
+
/**
* Complete class string for dropdown panel container
* Usage:
...
@@ -390,17 +406,10 @@ export const DROPDOWN_CLASSES = {
].join(" "),
/** Search input container */
- searchContainer: [
- "flex",
- "shrink-0",
- "items-center",
- DROPDOWN_ITEM.gapClass,
- "px-3",
- "py-1.5",
- "border-b",
- "border-solid",
- "border-border-2",
- ].join(" "),
+ searchContainer: PANEL_HEADER_ROW,
+
+ /** Panel header row carrying a title and actions instead of a search input. */
+ panelHeaderRow: PANEL_HEADER_ROW,
/** Search input */
searchInput: [
diff --git a/src/hooks/git/useBranchPullRequestStatus.test.ts b/src/hooks/git/useBranchPullRequestStatus.test.ts
index e6c03f76b4..a0cfbaa8b6 100644
--- a/src/hooks/git/useBranchPullRequestStatus.test.ts
+++ b/src/hooks/git/useBranchPullRequestStatus.test.ts
@@ -20,7 +20,11 @@ import {
getGitCredentialForRemote,
getPRLocal,
} from "@src/api/tauri/github";
-import { clearBranchPullRequestStatusCache } from "@src/services/git/branchPullRequestStatus";
+import {
+ BRANCH_CI_POLL_BASE_MS,
+ BRANCH_CI_POLL_MAX_MS,
+ clearBranchPullRequestStatusCache,
+} from "@src/services/git/branchPullRequestStatus";
import {
type UseBranchPullRequestStatusOptions,
@@ -142,9 +146,31 @@ describe("useBranchPullRequestStatus", () => {
act(() => root.unmount());
container.remove();
Reflect.deleteProperty(document, "visibilityState");
+ vi.useRealTimers();
vi.clearAllMocks();
});
+ function runningChecks() {
+ return {
+ sha: "abc",
+ state: "pending",
+ check_runs: [
+ {
+ id: 1,
+ name: "test",
+ status: "in_progress",
+ conclusion: null,
+ details_url: null,
+ started_at: null,
+ completed_at: null,
+ output_title: null,
+ app_name: "CI",
+ },
+ ],
+ statuses: [],
+ };
+ }
+
afterAll(() => {
Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT");
});
@@ -274,6 +300,69 @@ describe("useBranchPullRequestStatus", () => {
expect(latest.compareUrl).toContain("feature-new");
});
+ it("re-reads while checks run and stops once they settle", async () => {
+ vi.useFakeTimers();
+ getChecksLocalMock
+ .mockResolvedValueOnce(runningChecks())
+ .mockResolvedValueOnce(runningChecks());
+
+ await act(async () => {
+ root.render(
+ createElement(Probe, {
+ options: {
+ repoId: "repo-1",
+ repoPath: "/repo",
+ branchName: "feature",
+ poll: true,
+ },
+ onValue: () => undefined,
+ })
+ );
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS);
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(2);
+
+ // Second poll returns the settled default (`success`), so the schedule ends
+ // even though far more than the max interval elapses afterwards.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_BASE_MS * 2);
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(3);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4);
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("never schedules a poll when tracing is not requested", async () => {
+ vi.useFakeTimers();
+ getChecksLocalMock.mockResolvedValue(runningChecks());
+
+ await act(async () => {
+ root.render(
+ createElement(Probe, {
+ options: {
+ repoId: "repo-1",
+ repoPath: "/repo",
+ branchName: "feature",
+ },
+ onValue: () => undefined,
+ })
+ );
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(BRANCH_CI_POLL_MAX_MS * 4);
+ });
+ expect(getChecksLocalMock).toHaveBeenCalledTimes(1);
+ });
+
it("does not expose a closed PR or request checks for it", async () => {
findPullRequestLocalMock.mockResolvedValue({
number: 12,
diff --git a/src/hooks/git/useBranchPullRequestStatus.ts b/src/hooks/git/useBranchPullRequestStatus.ts
index 21c88cbc4e..28b164907b 100644
--- a/src/hooks/git/useBranchPullRequestStatus.ts
+++ b/src/hooks/git/useBranchPullRequestStatus.ts
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getGitDefaultBranch } from "@src/api/http/git/branches";
import { getGitRemotes } from "@src/api/http/git/remotes";
@@ -18,6 +18,7 @@ import {
getCachedBranchPullRequestStatus,
isBranchPullRequestStatusFresh,
loadBranchPullRequestStatusCoalesced,
+ nextBranchCiPollDelayMs,
resolveBranchCiStatus,
setCachedBranchPullRequestStatus,
} from "@src/services/git/branchPullRequestStatus";
@@ -50,6 +51,13 @@ export interface UseBranchPullRequestStatusOptions {
branchName?: string;
repoId?: string;
repoPath?: string;
+ /**
+ * Keep re-reading CI while checks can still change, stopping once every run
+ * has reported. Off by default — only surfaces that visibly trace CI (the
+ * status-bar menu) should pay for the extra requests. See
+ * {@link nextBranchCiPollDelayMs} for the schedule.
+ */
+ poll?: boolean;
}
export interface UseBranchPullRequestStatusResult extends Omit<
@@ -57,6 +65,8 @@ export interface UseBranchPullRequestStatusResult extends Omit<
"scopeKey"
> {
ciStatus: BranchCiStatus | null;
+ /** Re-reads PR + checks now, bypassing the status TTL. */
+ refresh: () => void;
}
function isGitHubRemote(remoteUrl: string): boolean {
@@ -111,9 +121,16 @@ export function useBranchPullRequestStatus({
branchName,
repoId,
repoPath,
+ poll = false,
}: UseBranchPullRequestStatusOptions): UseBranchPullRequestStatusResult {
const [state, setState] = useState(EMPTY_STATE);
const generationRef = useRef(0);
+ const loadRef = useRef<((options?: { force?: boolean }) => void) | null>(
+ null
+ );
+ const pollTimerRef = useRef(null);
+ const pollAttemptRef = useRef(0);
+ const pollHeadShaRef = useRef(null);
const scopeKey =
repoPath && branchName
? `${repoId ?? "default"}|${repoPath}|${branchName}`
@@ -122,18 +139,56 @@ export function useBranchPullRequestStatus({
useEffect(() => {
let disposed = false;
+ const clearPollTimer = () => {
+ if (pollTimerRef.current != null) {
+ window.clearTimeout(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ };
+
+ // A different repo/branch is a different CI timeline — restart the backoff.
+ pollAttemptRef.current = 0;
+ pollHeadShaRef.current = null;
+
if (!repoPath || !branchName) {
generationRef.current += 1;
+ loadRef.current = null;
+ clearPollTimer();
return;
}
- const load = async () => {
+ // A new head commit restarts the backoff, so a push is picked up at the
+ // fast interval instead of inheriting the previous run's cooled-off delay.
+ const scheduleNextPoll = (snapshot: BranchPullRequestStatusSnapshot) => {
+ clearPollTimer();
+ if (!poll || disposed) return;
+
+ const headSha = snapshot.checks?.sha ?? null;
+ if (headSha !== pollHeadShaRef.current) {
+ pollHeadShaRef.current = headSha;
+ pollAttemptRef.current = 0;
+ }
+
+ const delay = nextBranchCiPollDelayMs({
+ ...snapshot,
+ attempt: pollAttemptRef.current,
+ });
+ if (delay == null) return;
+
+ pollAttemptRef.current += 1;
+ pollTimerRef.current = window.setTimeout(() => {
+ loadRef.current?.({ force: true });
+ }, delay);
+ };
+
+ const load = async (options?: { force?: boolean }) => {
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
+ const force = options?.force === true;
const generation = ++generationRef.current;
const isCurrent = () => !disposed && generation === generationRef.current;
@@ -156,12 +211,14 @@ export function useBranchPullRequestStatus({
(remote) => remote.name === "origin"
);
if (!origin?.url || !isGitHubRemote(origin.url)) {
+ clearPollTimer();
setState(EMPTY_STATE);
return;
}
const repoFullName = parseGithubRepoFullName(origin.url);
if (!repoFullName) {
+ clearPollTimer();
setState(EMPTY_STATE);
return;
}
@@ -183,7 +240,7 @@ export function useBranchPullRequestStatus({
repoFullName,
});
const cached = getCachedBranchPullRequestStatus(cacheKey);
- const cachedIsFresh = isBranchPullRequestStatusFresh(cached);
+ const cachedIsFresh = !force && isBranchPullRequestStatusFresh(cached);
setState({
compareUrl,
@@ -196,7 +253,10 @@ export function useBranchPullRequestStatus({
refreshing: Boolean(cached && !cachedIsFresh),
scopeKey,
});
- if (cachedIsFresh) return;
+ if (cachedIsFresh) {
+ if (cached) scheduleNextPoll(cached);
+ return;
+ }
try {
const snapshot = await loadBranchPullRequestStatusCoalesced(
@@ -214,8 +274,12 @@ export function useBranchPullRequestStatus({
refreshing: false,
scopeKey,
});
+ scheduleNextPoll(snapshot);
} catch {
if (!isCurrent()) return;
+ // Leave the poll timer cleared: a failed read is retried by an
+ // explicit refresh or the next visibility return, not by a timer.
+ clearPollTimer();
setState((current) => ({
...current,
loading: false,
@@ -224,9 +288,16 @@ export function useBranchPullRequestStatus({
}
};
+ loadRef.current = (options) => {
+ void load(options);
+ };
+
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void load();
+ } else {
+ // Nothing to trace behind a hidden window; the return trip re-reads.
+ clearPollTimer();
}
};
@@ -243,6 +314,8 @@ export function useBranchPullRequestStatus({
return () => {
disposed = true;
generationRef.current += 1;
+ loadRef.current = null;
+ clearPollTimer();
if (typeof document !== "undefined") {
document.removeEventListener(
"visibilitychange",
@@ -250,7 +323,7 @@ export function useBranchPullRequestStatus({
);
}
};
- }, [branchName, repoId, repoPath, scopeKey]);
+ }, [branchName, poll, repoId, repoPath, scopeKey]);
const visibleState =
state.scopeKey === scopeKey
@@ -277,6 +350,13 @@ export function useBranchPullRequestStatus({
]
);
+ const refresh = useCallback(() => {
+ // Asking by hand is a signal of interest: drop back to the fast interval
+ // instead of inheriting however far the backoff had already cooled.
+ pollAttemptRef.current = 0;
+ loadRef.current?.({ force: true });
+ }, []);
+
const { scopeKey: _scopeKey, ...result } = visibleState;
- return { ...result, ciStatus };
+ return { ...result, ciStatus, refresh };
}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 380e1c323f..95790e78d2 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -1142,6 +1142,18 @@
"searchPlaceholder": "Search ports…",
"noSearchResults": "No matching ports"
},
+ "ci": {
+ "openPullRequest": "Open pull request on GitHub",
+ "viewDetails": "View check details",
+ "refresh": "Refresh checks",
+ "unavailable": "Checks could not be read",
+ "sections": {
+ "failed": "Failed",
+ "running": "Running",
+ "skipped": "Skipped",
+ "passed": "Passed"
+ }
+ },
"noLanguageServicesActive": "No language services active",
"linesSelected": "{{count}} lines selected",
"charsSelected": "{{count}} selected",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index f594a5ca7d..c27681e291 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -1130,6 +1130,18 @@
"searchPlaceholder": "搜索端口…",
"noSearchResults": "没有匹配的端口"
},
+ "ci": {
+ "openPullRequest": "在 GitHub 中打开 Pull Request",
+ "viewDetails": "查看检查详情",
+ "refresh": "刷新检查",
+ "unavailable": "无法读取检查状态",
+ "sections": {
+ "failed": "失败",
+ "running": "运行中",
+ "skipped": "已跳过",
+ "passed": "已通过"
+ }
+ },
"noLanguageServicesActive": "没有活跃的 Language Service",
"linesSelected": "已选择 {{count}} 行",
"charsSelected": "已选择 {{count}} 个字符",
diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx
index dce1ff3fdf..8bb93231fc 100644
--- a/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx
+++ b/src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChecksTab.tsx
@@ -14,52 +14,17 @@ import {
import React from "react";
import { useTranslation } from "react-i18next";
-import type {
- GitHubCheckRun,
- GitHubChecksSummary,
- GitHubStatusContext,
-} from "@src/api/tauri/github";
+import type { GitHubChecksSummary } from "@src/api/tauri/github";
import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens";
import { formatTimeAgo } from "@src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/hooks/workstationIssueHelpers";
import { Placeholder } from "@src/modules/shared/layouts/blocks";
+import {
+ type CiCheckState,
+ checkRunState,
+ statusContextState,
+} from "@src/services/git/ciCheckState";
-type CheckState = "success" | "failure" | "pending" | "neutral";
-
-function checkRunState(run: GitHubCheckRun): CheckState {
- if (run.status !== "completed") return "pending";
- switch (run.conclusion) {
- case "success":
- return "success";
- case "failure":
- case "timed_out":
- case "action_required":
- case "cancelled":
- case "startup_failure":
- return "failure";
- case "neutral":
- case "skipped":
- case "stale":
- return "neutral";
- default:
- return "pending";
- }
-}
-
-function statusState(status: GitHubStatusContext): CheckState {
- switch (status.state) {
- case "success":
- return "success";
- case "failure":
- case "error":
- return "failure";
- case "pending":
- return "pending";
- default:
- return "neutral";
- }
-}
-
-function StateIcon({ state }: { state: CheckState }): React.ReactNode {
+function StateIcon({ state }: { state: CiCheckState }): React.ReactNode {
switch (state) {
case "success":
return (
@@ -83,7 +48,7 @@ function StateIcon({ state }: { state: CheckState }): React.ReactNode {
}
interface CheckRowProps {
- state: CheckState;
+ state: CiCheckState;
name: string;
description?: string | null;
meta?: string | null;
@@ -165,7 +130,7 @@ export const PrChecksTab: React.FC = ({
);
}
- const overall = (checks?.state ?? "pending") as CheckState;
+ const overall = (checks?.state ?? "pending") as CiCheckState;
const summaryLabel =
overall === "success"
? t("git.pr.checks.allPassed", "All checks passed")
@@ -203,7 +168,7 @@ export const PrChecksTab: React.FC = ({
{statuses.map((status) => (
+ );
+ case "failure":
+ return (
+
+ );
+ case "pending":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+}
+
+function BranchCiIcon({ status }: { status: BranchCiStatus }): React.ReactNode {
+ switch (status) {
+ case "success":
+ return ;
+ case "failure":
+ return ;
+ case "pending":
+ case "checking":
+ return ;
+ default:
+ return (
+
+ );
+ }
+}
+
+interface CheckRowProps {
+ item: CiCheckItem;
+ onOpenDetails: (url: string) => void;
+}
+
+const CheckRow: React.FC = memo(({ item, onOpenDetails }) => {
+ const { t } = useTranslation();
+ // Elapsed time only earns space while a check is still running — once it has
+ // a verdict, the verdict is the answer and "35m ago" is noise.
+ const meta =
+ item.state === "pending" && item.startedAt
+ ? formatRelativeTime(item.startedAt, "nano")
+ : null;
+ // The reporting app is the same for nearly every row, so it only earns the
+ // narrow status-bar width in the hover title, not in the label.
+ const title = [
+ item.appName ? `${item.appName} / ${item.name}` : item.name,
+ item.description,
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ return (
+
+
+
+
+
+
+ {item.name}
+
+ {meta && (
+ {meta}
+ )}
+
+ {/*
+ Actions stay painted rather than revealed on hover — same reasoning as
+ the ports rows: opacity transitions promote compositor layers and make
+ the centered icon jitter in Chromium.
+ */}
+
+ {item.detailsUrl && (
+
{
+ event.stopPropagation();
+ onOpenDetails(item.detailsUrl as string);
+ }}
+ >
+
+
+ )}
+
+
+ );
+});
+CheckRow.displayName = "CheckRow";
+
+export const CiStatusMenu: React.FC = memo(
+ ({ branchName }) => {
+ const { t } = useTranslation();
+ const { repoId, repoPath } = useActiveRepoRef();
+
+ const { checks, ciStatus, pr, refresh, refreshing } =
+ useBranchPullRequestStatus({
+ branchName,
+ repoId,
+ repoPath,
+ poll: true,
+ });
+
+ const {
+ close,
+ isOpen,
+ isPositioned,
+ panelPosition,
+ panelRef,
+ toggle,
+ triggerRef,
+ } = useDropdownEngine({
+ align: "left",
+ gap: DROPDOWN_PANEL.triggerGap,
+ placement: "top",
+ });
+
+ const items = useMemo(() => flattenChecks(checks), [checks]);
+ const counts = useMemo(() => countCheckStates(items), [items]);
+
+ const sections = useMemo(
+ () =>
+ SECTION_ORDER.map((state) => ({
+ state,
+ items: items.filter((item) => item.state === state),
+ })).filter((section) => section.items.length > 0),
+ [items]
+ );
+
+ const handleToggle = useCallback(() => {
+ // Opening is the user asking "where is it now?" — always re-read, even
+ // when the background schedule has already settled and stopped.
+ if (!isOpen) refresh();
+ toggle();
+ }, [isOpen, refresh, toggle]);
+
+ const handleOpenDetails = useCallback(
+ (url: string) => {
+ void openExternalLink(url);
+ close();
+ },
+ [close]
+ );
+
+ const handleOpenPullRequest = useCallback(() => {
+ if (!pr) return;
+ void openExternalLink(pr.url);
+ close();
+ }, [close, pr]);
+
+ const statusLabel = useMemo(() => {
+ switch (ciStatus) {
+ case "success":
+ return t("git.pr.checks.passedShort");
+ case "failure":
+ return t("git.pr.checks.failedShort");
+ case "pending":
+ return t("git.pr.checks.runningShort");
+ case "checking":
+ return t("git.pr.checks.checkingShort");
+ case "none":
+ return t("git.pr.checks.noneShort");
+ default:
+ return t("git.pr.checks.unavailableShort");
+ }
+ }, [ciStatus, t]);
+
+ const sectionLabelFor = useCallback(
+ (state: CiCheckState, count: number) => {
+ const label =
+ state === "failure"
+ ? t("workstation.ci.sections.failed")
+ : state === "pending"
+ ? t("workstation.ci.sections.running")
+ : state === "neutral"
+ ? t("workstation.ci.sections.skipped")
+ : t("workstation.ci.sections.passed");
+ return `${label} · ${count}`;
+ },
+ [t]
+ );
+
+ // Nothing to trace without an open pull request for this branch — the
+ // compare/create affordances live in the git sync menu next door.
+ if (!pr || !ciStatus) return null;
+
+ const reportedCount = counts.total - counts.pending;
+ const triggerLabel =
+ counts.pending > 0
+ ? `#${pr.number} · ${reportedCount}/${counts.total}`
+ : `#${pr.number}`;
+ const triggerTooltip = t("git.pr.checks.branchStatus", {
+ number: pr.number,
+ status: statusLabel,
+ });
+
+ return (
+
+
+
+
+
+ {triggerLabel}
+
+
+
+
+ {isOpen &&
+ isPositioned &&
+ createPortal(
+
+ {/*
+ The header row token carries no font size — the search variant
+ gets it from the input instead. Set it here so the title and
+ status read at the same 13px as the check rows below rather
+ than inheriting the document default.
+ */}
+
+
+
+ {t("git.pr.linkedBranch", { number: pr.number })}
+
+ {statusLabel}
+
+
+
+
+
+
+ {sections.length === 0 ? (
+
+ {ciStatus === "unavailable"
+ ? t("workstation.ci.unavailable")
+ : ciStatus === "checking"
+ ? t("git.pr.checks.checkingShort")
+ : t("git.pr.checks.none")}
+
+ ) : (
+ sections.map((section) => (
+
+
+ {sectionLabelFor(section.state, section.items.length)}
+
+ {section.items.map((item) => (
+
+ ))}
+
+ ))
+ )}
+
+
,
+ document.body
+ )}
+
+ );
+ }
+);
+CiStatusMenu.displayName = "CiStatusMenu";
+
+export default CiStatusMenu;
diff --git a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx
index 7b4714b41d..f9f6db7072 100644
--- a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx
+++ b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx
@@ -44,6 +44,7 @@ import {
} from "@src/store/workstation/codeEditor/search/indexingProgressAtom";
import { getViewportSize } from "@src/util/ui/window/viewport";
+import { CiStatusMenu } from "./CiStatusMenu";
import GitSyncStatusMenu from "./GitSyncStatusMenu";
import { PortsStatusMenu } from "./PortsStatusMenu";
import {
@@ -310,6 +311,10 @@ export const EditorStatusBar: React.FC = memo(
)}
+ {showGitControls && branchName && (
+
+ )}
+
{showGitControls && branchName && (
{
vi.useRealTimers();
});
+ it("stops polling when there is nothing left to trace", () => {
+ const base = { attempt: 0, checksUnavailable: false };
+
+ // No PR, unreadable checks, and settled checks all end the schedule.
+ expect(
+ nextBranchCiPollDelayMs({ ...base, pr: null, checks: checks("pending") })
+ ).toBeNull();
+ expect(
+ nextBranchCiPollDelayMs({
+ ...base,
+ pr,
+ checks: null,
+ checksUnavailable: true,
+ })
+ ).toBeNull();
+ expect(
+ nextBranchCiPollDelayMs({ ...base, pr, checks: checks("success") })
+ ).toBeNull();
+ expect(
+ nextBranchCiPollDelayMs({ ...base, pr, checks: checks("failure") })
+ ).toBeNull();
+ });
+
+ it("backs off while checks run and caps the interval", () => {
+ const running = { checks: checks("pending"), checksUnavailable: false, pr };
+
+ expect(nextBranchCiPollDelayMs({ ...running, attempt: 0 })).toBe(
+ BRANCH_CI_POLL_BASE_MS
+ );
+ expect(nextBranchCiPollDelayMs({ ...running, attempt: 1 })).toBe(
+ BRANCH_CI_POLL_BASE_MS * 2
+ );
+ expect(nextBranchCiPollDelayMs({ ...running, attempt: 9 })).toBe(
+ BRANCH_CI_POLL_MAX_MS
+ );
+ });
+
+ it("gives an unreported PR a bounded grace period before giving up", () => {
+ const empty = {
+ checks: checks("pending", false),
+ checksUnavailable: false,
+ pr,
+ };
+
+ expect(nextBranchCiPollDelayMs({ ...empty, attempt: 0 })).toBe(
+ BRANCH_CI_EMPTY_POLL_MS
+ );
+ expect(
+ nextBranchCiPollDelayMs({
+ ...empty,
+ attempt: BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS - 1,
+ })
+ ).toBe(BRANCH_CI_EMPTY_POLL_MS);
+ expect(
+ nextBranchCiPollDelayMs({
+ ...empty,
+ attempt: BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS,
+ })
+ ).toBeNull();
+ });
+
it("builds GitHub compare links and falls back to the compare picker", () => {
expect(buildGitHubCompareUrl("acme/repo", "main", "feature/a")).toBe(
"https://github.com/acme/repo/compare/main...feature%2Fa"
diff --git a/src/services/git/branchPullRequestStatus.ts b/src/services/git/branchPullRequestStatus.ts
index 4db98af8f9..b250e86116 100644
--- a/src/services/git/branchPullRequestStatus.ts
+++ b/src/services/git/branchPullRequestStatus.ts
@@ -2,10 +2,20 @@ import type {
GitHubChecksSummary,
LocalFindPRResponse,
} from "@src/api/tauri/github";
+import { areChecksSettled } from "@src/services/git/ciCheckState";
export const BRANCH_PULL_REQUEST_STATUS_TTL_MS = 45_000;
export const BRANCH_PULL_REQUEST_STATUS_CACHE_MAX_ENTRIES = 8;
+/** First poll delay while checks are still running. */
+export const BRANCH_CI_POLL_BASE_MS = 15_000;
+/** Ceiling for the running-checks backoff. */
+export const BRANCH_CI_POLL_MAX_MS = 60_000;
+/** Delay between the bounded retries taken when a PR reports no checks yet. */
+export const BRANCH_CI_EMPTY_POLL_MS = 30_000;
+/** How many times to re-ask before accepting that a PR simply has no CI. */
+export const BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS = 3;
+
export interface BranchPullRequestStatusSnapshot {
pr: LocalFindPRResponse | null;
checks: GitHubChecksSummary | null;
@@ -142,6 +152,41 @@ export function resolveBranchCiStatus({
}
}
+/**
+ * Delay before the next branch-CI poll, or `null` to stop polling.
+ *
+ * Tracing a branch's CI the way GitHub Desktop does, without its polling cost:
+ * we only keep asking while something can still change. Once every run has
+ * reported — or there is no PR and no CI to watch at all — the schedule ends
+ * and the next read comes from an explicit trigger (opening the menu,
+ * switching branch, or the window becoming visible again).
+ *
+ * @param attempt Consecutive polls already scheduled for this head commit.
+ * Callers reset it whenever the head SHA changes, so a new push restarts at
+ * the fast interval.
+ */
+export function nextBranchCiPollDelayMs({
+ attempt,
+ checks,
+ checksUnavailable,
+ pr,
+}: BranchPullRequestStatusSnapshot & { attempt: number }): number | null {
+ // No PR to trace, or checks we couldn't read — nothing a timer would fix.
+ if (!pr || checksUnavailable || !checks) return null;
+
+ if (checks.check_runs.length === 0 && checks.statuses.length === 0) {
+ // CI may not have registered its runs yet; give it a bounded grace period
+ // rather than polling an un-CI'd repository forever.
+ return attempt < BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS
+ ? BRANCH_CI_EMPTY_POLL_MS
+ : null;
+ }
+
+ if (areChecksSettled(checks)) return null;
+
+ return Math.min(BRANCH_CI_POLL_BASE_MS * 2 ** attempt, BRANCH_CI_POLL_MAX_MS);
+}
+
export function branchPullRequestStatusCacheSize(): number {
return statusCache.size;
}
diff --git a/src/services/git/ciCheckState.test.ts b/src/services/git/ciCheckState.test.ts
new file mode 100644
index 0000000000..84dd8ce48a
--- /dev/null
+++ b/src/services/git/ciCheckState.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ GitHubCheckRun,
+ GitHubChecksSummary,
+ GitHubStatusContext,
+} from "@src/api/tauri/github";
+
+import {
+ areChecksSettled,
+ checkRunState,
+ countCheckStates,
+ flattenChecks,
+ statusContextState,
+} from "./ciCheckState";
+
+function run(overrides: Partial = {}): GitHubCheckRun {
+ return {
+ id: 1,
+ name: "build",
+ status: "completed",
+ conclusion: "success",
+ details_url: "https://github.com/acme/repo/runs/1",
+ started_at: null,
+ completed_at: null,
+ output_title: null,
+ app_name: null,
+ ...overrides,
+ };
+}
+
+function status(
+ overrides: Partial = {}
+): GitHubStatusContext {
+ return {
+ context: "ci/legacy",
+ state: "success",
+ description: null,
+ target_url: null,
+ avatar_url: null,
+ ...overrides,
+ };
+}
+
+function summary(
+ check_runs: GitHubCheckRun[],
+ statuses: GitHubStatusContext[] = []
+): GitHubChecksSummary {
+ return { sha: "abc123", check_runs, statuses, state: "pending" };
+}
+
+describe("ciCheckState", () => {
+ it("treats an in-flight run and a completed-without-conclusion run as pending", () => {
+ expect(checkRunState(run({ status: "queued", conclusion: null }))).toBe(
+ "pending"
+ );
+ expect(
+ checkRunState(run({ status: "in_progress", conclusion: null }))
+ ).toBe("pending");
+ expect(checkRunState(run({ conclusion: null }))).toBe("pending");
+ });
+
+ it("maps every failing conclusion to failure and non-verdicts to neutral", () => {
+ for (const conclusion of [
+ "failure",
+ "timed_out",
+ "action_required",
+ "cancelled",
+ "startup_failure",
+ ]) {
+ expect(checkRunState(run({ conclusion }))).toBe("failure");
+ }
+ for (const conclusion of ["neutral", "skipped", "stale"]) {
+ expect(checkRunState(run({ conclusion }))).toBe("neutral");
+ }
+ expect(statusContextState(status({ state: "error" }))).toBe("failure");
+ expect(statusContextState(status({ state: "pending" }))).toBe("pending");
+ });
+
+ it("flattens runs before statuses and keeps the app name unmerged", () => {
+ const items = flattenChecks(
+ summary(
+ [run({ id: 7, name: "test", app_name: "GitHub Actions" })],
+ [status({ context: "ci/legacy" })]
+ )
+ );
+
+ expect(items.map((item) => item.key)).toEqual([
+ "run-7",
+ "status-ci/legacy",
+ ]);
+ // The reporting app stays a separate field so the label can drop it.
+ expect(items[0].name).toBe("test");
+ expect(items[0].appName).toBe("GitHub Actions");
+ expect(items[1].name).toBe("ci/legacy");
+ expect(items[1].appName).toBeNull();
+ });
+
+ it("counts states across runs and statuses", () => {
+ const counts = countCheckStates(
+ flattenChecks(
+ summary(
+ [
+ run({ id: 1 }),
+ run({ id: 2, status: "in_progress", conclusion: null }),
+ run({ id: 3, conclusion: "failure" }),
+ run({ id: 4, conclusion: "skipped" }),
+ ],
+ [status({ state: "pending" })]
+ )
+ )
+ );
+
+ expect(counts).toEqual({
+ total: 5,
+ success: 1,
+ failure: 1,
+ pending: 2,
+ neutral: 1,
+ });
+ });
+
+ it("settles only once nothing is still pending", () => {
+ expect(areChecksSettled(null)).toBe(false);
+ // Empty is not settled — CI may not have registered its runs yet.
+ expect(areChecksSettled(summary([]))).toBe(false);
+ expect(
+ areChecksSettled(
+ summary([run({ status: "in_progress", conclusion: null })])
+ )
+ ).toBe(false);
+ // A decided failure alongside a still-running job is not settled: the rows
+ // keep moving even though the roll-up verdict cannot change.
+ expect(
+ areChecksSettled(
+ summary([
+ run({ id: 1, conclusion: "failure" }),
+ run({ id: 2, status: "in_progress", conclusion: null }),
+ ])
+ )
+ ).toBe(false);
+ expect(
+ areChecksSettled(summary([run({ conclusion: "failure" })], [status()]))
+ ).toBe(true);
+ expect(areChecksSettled(summary([], [status({ state: "pending" })]))).toBe(
+ false
+ );
+ });
+});
diff --git a/src/services/git/ciCheckState.ts b/src/services/git/ciCheckState.ts
new file mode 100644
index 0000000000..ff14cc24b6
--- /dev/null
+++ b/src/services/git/ciCheckState.ts
@@ -0,0 +1,142 @@
+/**
+ * CI check-state derivation shared by every surface that renders GitHub
+ * checks — the PR detail panel and the status-bar CI menu.
+ *
+ * `github_get_checks` returns two shapes for the same head commit (modern
+ * check-runs and legacy commit statuses). Both surfaces need the same
+ * per-check verdict and the same notion of "everything has reported", so the
+ * mapping lives here instead of being re-derived per component.
+ */
+import type {
+ GitHubCheckRun,
+ GitHubChecksSummary,
+ GitHubStatusContext,
+} from "@src/api/tauri/github";
+
+export type CiCheckState = "success" | "failure" | "pending" | "neutral";
+
+export function checkRunState(run: GitHubCheckRun): CiCheckState {
+ if (run.status !== "completed") return "pending";
+ switch (run.conclusion) {
+ case "success":
+ return "success";
+ case "failure":
+ case "timed_out":
+ case "action_required":
+ case "cancelled":
+ case "startup_failure":
+ return "failure";
+ case "neutral":
+ case "skipped":
+ case "stale":
+ return "neutral";
+ default:
+ // `completed` without a conclusion — GitHub still owes us a verdict.
+ return "pending";
+ }
+}
+
+export function statusContextState(status: GitHubStatusContext): CiCheckState {
+ switch (status.state) {
+ case "success":
+ return "success";
+ case "failure":
+ case "error":
+ return "failure";
+ case "pending":
+ return "pending";
+ default:
+ return "neutral";
+ }
+}
+
+export interface CiCheckItem {
+ key: string;
+ /** Check name on its own — callers decide whether to qualify it. */
+ name: string;
+ /** Reporting app ("GitHub Actions"), or null for legacy commit statuses. */
+ appName: string | null;
+ description: string | null;
+ state: CiCheckState;
+ detailsUrl: string | null;
+ /** ISO timestamp, or null for legacy commit statuses (no timing reported). */
+ startedAt: string | null;
+ completedAt: string | null;
+}
+
+/**
+ * Flattens check-runs and commit statuses into one list, preserving the order
+ * GitHub returned them in (runs first, then statuses) so callers that render a
+ * flat list keep the API's ordering.
+ */
+export function flattenChecks(
+ summary: GitHubChecksSummary | null
+): CiCheckItem[] {
+ if (!summary) return [];
+
+ const items: CiCheckItem[] = summary.check_runs.map((run) => ({
+ key: `run-${run.id}`,
+ name: run.name,
+ appName: run.app_name,
+ description: run.output_title,
+ state: checkRunState(run),
+ detailsUrl: run.details_url,
+ startedAt: run.started_at,
+ completedAt: run.completed_at,
+ }));
+
+ for (const status of summary.statuses) {
+ items.push({
+ key: `status-${status.context}`,
+ name: status.context,
+ appName: null,
+ description: status.description,
+ state: statusContextState(status),
+ detailsUrl: status.target_url,
+ startedAt: null,
+ completedAt: null,
+ });
+ }
+
+ return items;
+}
+
+export interface CiCheckCounts {
+ total: number;
+ success: number;
+ failure: number;
+ pending: number;
+ neutral: number;
+}
+
+export function countCheckStates(items: CiCheckItem[]): CiCheckCounts {
+ const counts: CiCheckCounts = {
+ total: items.length,
+ success: 0,
+ failure: 0,
+ pending: 0,
+ neutral: 0,
+ };
+ for (const item of items) {
+ counts[item.state] += 1;
+ }
+ return counts;
+}
+
+/**
+ * True once every reported check has a final verdict — nothing else can change
+ * without a new push.
+ *
+ * Deliberately structural rather than reading `summary.state`: the server-side
+ * roll-up short-circuits to `failure` while other runs are still in flight, and
+ * the CI menu keeps those rows live until they actually finish.
+ *
+ * An empty summary is *not* settled — CI may simply not have registered its
+ * runs yet, which the poll schedule handles with a bounded grace period.
+ */
+export function areChecksSettled(summary: GitHubChecksSummary | null): boolean {
+ if (!summary) return false;
+ const items = flattenChecks(summary);
+ if (items.length === 0) return false;
+ return items.every((item) => item.state !== "pending");
+}