From 50dd301cbeb14f4d6cbecc9f92648c7113c7e18c Mon Sep 17 00:00:00 2001
From: SudoMaggie <268191359+sudomaggie@users.noreply.github.com>
Date: Sun, 2 Aug 2026 16:17:13 +0800
Subject: [PATCH] feat(status-bar): trace branch PR and CI status
Adds a CI menu to the workstation status bar, between the branch and the
git sync control: the pull request open for the current branch plus the
state of every check on its head commit, grouped failed / running /
skipped / passed. It reuses the ports menu's trigger and portalled panel
so the two neighbours read as one control family.
Polling is bounded rather than periodic. `useBranchPullRequestStatus`
gains an opt-in `poll` mode whose schedule comes from
`nextBranchCiPollDelayMs`: 15s backing off to a 60s cap while checks run,
three 30s retries when a PR has not reported any checks yet, and no timer
at all once every check has a verdict, when there is no PR, or when the
window is hidden. A new head SHA restarts the backoff so a push is picked
up quickly. Opening the menu or the refresh button forces a read past the
45s cache TTL. The focused-chat rail keeps polling off and shares the same
cache and in-flight coalescing, so nothing is fetched twice.
Check-state derivation moves to `services/git/ciCheckState`, which
`PrChecksTab` now shares instead of re-deriving it. "Settled" is
structural (every run reported) rather than reading the server roll-up,
which short-circuits to failure while other jobs are still in flight.
Pre-commit hook ran. Total eslint: 0, total circular: 0
---
src/components/Dropdown/tokens.ts | 31 +-
.../git/useBranchPullRequestStatus.test.ts | 91 +++-
src/hooks/git/useBranchPullRequestStatus.ts | 92 ++++-
src/i18n/locales/en/common.json | 12 +
src/i18n/locales/zh/common.json | 12 +
.../PullRequestContent/detail/PrChecksTab.tsx | 55 +--
.../shared/StatusBar/CiStatusMenu.tsx | 388 ++++++++++++++++++
.../shared/StatusBar/EditorStatusBar.tsx | 5 +
.../WorkStation/shared/StatusBar/index.ts | 1 +
.../git/branchPullRequestStatus.test.ts | 66 +++
src/services/git/branchPullRequestStatus.ts | 45 ++
src/services/git/ciCheckState.test.ts | 149 +++++++
src/services/git/ciCheckState.ts | 142 +++++++
13 files changed, 1026 insertions(+), 63 deletions(-)
create mode 100644 src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx
create mode 100644 src/services/git/ciCheckState.test.ts
create mode 100644 src/services/git/ciCheckState.ts
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 (
+
+ {/*
+ 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.
+ */}
+
+ {/*
+ 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.
+ */}
+