diff --git a/.changeset/fn-7177-pr-badge-status-color.md b/.changeset/fn-7177-pr-badge-status-color.md
new file mode 100644
index 0000000000..c1c7c84d95
--- /dev/null
+++ b/.changeset/fn-7177-pr-badge-status-color.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: PR badge color now follows GitHub status: green/gray/purple/red plus a conflict color.
+category: fix
+dev: Adds getPrBadgeModifierClass and a token-backed --color-merged badge modifier.
diff --git a/packages/dashboard/app/components/GitHubBadge.tsx b/packages/dashboard/app/components/GitHubBadge.tsx
index 17de86e7b4..51780a17bb 100644
--- a/packages/dashboard/app/components/GitHubBadge.tsx
+++ b/packages/dashboard/app/components/GitHubBadge.tsx
@@ -1,6 +1,7 @@
import { GitPullRequest, CircleDot, CheckCircle2, XCircle, Clock } from "lucide-react";
import type { IssueInfo, PrInfo } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
+import { getPrBadgeModifierClass } from "../utils/prBadgeClass";
interface GitHubBadgeProps {
prInfo?: PrInfo;
@@ -17,19 +18,20 @@ function getIssueModifierClass(state: string, stateReason?: string): string {
}
export function GitHubBadge({ prInfo, issueInfo, onIssueRefresh: _onIssueRefresh }: GitHubBadgeProps) {
- const prState = prInfo?.isDraft || prInfo?.status === "draft" ? "draft" : prInfo?.status;
+ const prIsDraft = prInfo?.status === "draft" || (prInfo?.status === "open" && (prInfo.draft ?? prInfo.isDraft));
+ const prModifierClass = prInfo ? getPrBadgeModifierClass(prInfo) : null;
const checkRollup = prInfo?.checkRollup;
const checkClass = checkRollup && checkRollup !== "none" ? `card-github-badge__check card-github-badge__check--${checkRollup}` : null;
const checkTitle = checkRollup && checkRollup !== "none" ? ` — checks: ${checkRollup}` : "";
const prTitle = prInfo
- ? `PR #${prInfo.number}${prState === "draft" ? " (draft)" : ""}: ${prInfo.title}${checkTitle}`
+ ? `PR #${prInfo.number}${prIsDraft ? " (draft)" : ""}: ${prInfo.title}${checkTitle}`
: "";
return (
<>
{prInfo && (
{
const nextPr = nextTask.prInfos?.[index];
- return nextPr?.number === pr.number && nextPr?.status === pr.status;
+ /*
+ FNXC:PRBadgeStatusColor 2026-06-27-12:00:
+ Multi-PR badge rendering depends on the same live PR fields as getPrBadgeModifierClass, so memoization must compare the full badge payload instead of only number/status to repaint draft and conflict color changes.
+ */
+ return areTaskBadgeInfosEqual(pr, nextPr);
}) &&
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo)
);
@@ -1959,7 +1964,7 @@ function TaskCardComponent({
{(livePrInfo || liveIssueInfo) && (
<>
{livePrInfo && (task.prInfos?.length ?? 0) >= 2 ? (
-
+
{`${task.prInfos?.length}x #${livePrInfo.number}`}
diff --git a/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx b/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx
index ec332dbc27..221ed72649 100644
--- a/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx
+++ b/packages/dashboard/app/components/__tests__/GitHubBadge.test.tsx
@@ -18,6 +18,7 @@ describe("GitHubBadge PR state + rollup", () => {
it.each([
{ name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" },
{ name: "draft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" },
+ { name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
{ name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
{ name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" },
])("applies $name modifier class", ({ prInfo, expectedClass }) => {
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
index 0fd6169a52..1ce5a57e5e 100644
--- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
@@ -634,7 +634,10 @@ describe("TaskCard", () => {
}
});
- it("renders Nx PR badge label when multiple PRs are linked", () => {
+ it.each([
+ { name: "merged", primaryPr: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
+ { name: "conflicting", primaryPr: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
+ ])("renders Nx PR badge label and resolver class for $name primary PR", ({ primaryPr, expectedClass }) => {
render(
{
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
+ ...primaryPr,
} as any,
prInfos: [
{
@@ -657,6 +661,7 @@ describe("TaskCard", () => {
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
+ ...primaryPr,
},
{
url: "https://github.com/owner/repo/pull/99",
@@ -674,7 +679,9 @@ describe("TaskCard", () => {
/>,
);
- expect(screen.getByRole("link", { name: /2x #42/i })).toBeDefined();
+ const badge = screen.getByRole("link", { name: /2x #42/i });
+ expect(badge).toBeDefined();
+ expect(badge).toHaveClass(expectedClass);
});
it("clicking PR badge link does not open the task detail modal", () => {
@@ -1946,7 +1953,7 @@ describe("TaskCard", () => {
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
- // WS-003 has no result → name falls back to the humanized workflow id; all others resolve from result.workflowStepName.
+ // WS-003 has no result → name falls back to the display-normalized id; all others resolve from result.workflowStepName.
expect(stepNames).toEqual([
"Step 0",
"Step 1",
@@ -2032,7 +2039,7 @@ describe("TaskCard", () => {
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
- // Blank result name → fall back to the humanized id; WS-003 (no result) → humanized id.
+ // Blank result name → display-normalized id; WS-003 (no result) → display-normalized id.
expect(stepNames).toEqual(["WS 002", "WS 003"]);
});
@@ -4548,6 +4555,32 @@ describe("TaskCard memo comparator provenance behavior", () => {
).toBe(false);
});
+ it.each([
+ { name: "mergeable", patch: { mergeable: "conflicting" } },
+ { name: "draft", patch: { draft: true } },
+ { name: "isDraft", patch: { isDraft: true } },
+ ])("returns false when multi-PR badge $name color input changes", ({ patch }) => {
+ const basePr = {
+ url: "https://github.com/owner/repo/pull/42",
+ number: 42,
+ status: "open" as const,
+ title: "PR",
+ headBranch: "fusion/fn-001",
+ baseBranch: "main",
+ commentCount: 0,
+ };
+ const secondPr = { ...basePr, url: "https://github.com/owner/repo/pull/99", number: 99, title: "PR 2" };
+ const previousTask = makeTask({ prInfos: [basePr, secondPr] as any });
+ const nextTask = makeTask({ prInfos: [{ ...basePr, ...patch }, secondPr] as any });
+
+ expect(
+ __test_areTaskCardPropsEqual(
+ { task: previousTask, onOpenDetail: noop, addToast: noop } as any,
+ { task: nextTask, onOpenDetail: noop, addToast: noop } as any,
+ ),
+ ).toBe(false);
+ });
+
it("skips customFields JSON.stringify when both cardFieldDefs are absent", () => {
// Without cardFieldDefs present, two tasks with different customFields should
// compare equal (JSON.stringify is skipped — guard path).
diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css
index 70a19f5c2a..bba4c8ad69 100644
--- a/packages/dashboard/app/styles.css
+++ b/packages/dashboard/app/styles.css
@@ -299,6 +299,7 @@ svg.spinning {
--in-progress-rgb: 0, 229, 255;
--in-review: #3fb950;
--done: #8b949e;
+ --color-merged: #a371f7;
--color-success: #3fb950;
--color-error: #f85149;
@@ -548,6 +549,7 @@ svg.spinning {
--in-progress-rgb: 0, 188, 212;
--in-review: #1a7f37;
--done: #6e7781;
+ --color-merged: #8250df;
--color-success: #1a7f37;
--color-error: #cf222e;
--color-muted: #6e7781;
@@ -2171,7 +2173,10 @@ input[type="range"]:focus-visible {
box-shadow: var(--focus-ring-strong);
}
-/* GitHub badge theme-aware colors */
+/*
+ FNXC:PRBadgeStatusColor 2026-06-27-00:00:
+ PR badge modifiers map live GitHub status to theme tokens in both dark and light roots: open=green, draft=gray, merged=purple, closed=red, and conflicting/blocked=red-caution via --color-error. The existing mobile .card-github-badge base rule controls badge sizing, so modifiers need no breakpoint-specific overrides.
+*/
.card-github-badge--open {
background: color-mix(in srgb, var(--in-review) 20%, transparent);
color: var(--in-review);
@@ -2181,8 +2186,12 @@ input[type="range"]:focus-visible {
color: var(--color-error);
}
.card-github-badge--merged {
- background: color-mix(in srgb, var(--in-progress) 20%, transparent);
- color: var(--in-progress);
+ background: color-mix(in srgb, var(--color-merged) 20%, transparent);
+ color: var(--color-merged);
+}
+.card-github-badge--conflicting {
+ background: color-mix(in srgb, var(--color-error) 20%, transparent);
+ color: var(--color-error);
}
.card-github-badge--draft {
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
diff --git a/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts b/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts
new file mode 100644
index 0000000000..8db031843b
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/prBadgeClass.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { getPrBadgeModifierClass } from "../prBadgeClass";
+
+describe("getPrBadgeModifierClass", () => {
+ it.each([
+ { name: "open", prInfo: { status: "open" as const }, expectedClass: "card-github-badge--open" },
+ { name: "open isDraft", prInfo: { status: "open" as const, isDraft: true }, expectedClass: "card-github-badge--draft" },
+ { name: "open draft", prInfo: { status: "open" as const, draft: true }, expectedClass: "card-github-badge--draft" },
+ { name: "draft status", prInfo: { status: "draft" as const }, expectedClass: "card-github-badge--draft" },
+ { name: "merged", prInfo: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
+ { name: "closed", prInfo: { status: "closed" as const }, expectedClass: "card-github-badge--closed" },
+ { name: "conflicting", prInfo: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
+ { name: "blocked", prInfo: { status: "open" as const, mergeable: "blocked" as const }, expectedClass: "card-github-badge--conflicting" },
+ ])("maps $name PR state to $expectedClass", ({ prInfo, expectedClass }) => {
+ expect(getPrBadgeModifierClass(prInfo)).toBe(expectedClass);
+ });
+
+ it("prioritizes open conflicts over draft and open status colors", () => {
+ expect(getPrBadgeModifierClass({ status: "open", isDraft: true, mergeable: "conflicting" })).toBe("card-github-badge--conflicting");
+ expect(getPrBadgeModifierClass({ status: "open", draft: true, mergeable: "blocked" })).toBe("card-github-badge--conflicting");
+ });
+});
diff --git a/packages/dashboard/app/utils/prBadgeClass.ts b/packages/dashboard/app/utils/prBadgeClass.ts
new file mode 100644
index 0000000000..ba6c33e068
--- /dev/null
+++ b/packages/dashboard/app/utils/prBadgeClass.ts
@@ -0,0 +1,28 @@
+import type { PrInfo } from "@fusion/core";
+
+type PrBadgeClassInput = Pick;
+
+type PrBadgeModifierClass =
+ | "card-github-badge--conflicting"
+ | "card-github-badge--draft"
+ | "card-github-badge--open"
+ | "card-github-badge--merged"
+ | "card-github-badge--closed";
+
+/**
+ * FNXC:PRBadgeStatusColor 2026-06-27-00:00:
+ * PR badges must use one status-color source of truth across TaskCard's multi-PR link and GitHubBadge's single-PR link. Match GitHub's live PR conventions: open=green, draft=gray, merged=purple, closed=red, and open conflicts/blocks=red-caution while checks stay in their separate sub-badge.
+ */
+export function getPrBadgeModifierClass(prInfo: PrBadgeClassInput): PrBadgeModifierClass {
+ if (prInfo.status === "open" && (prInfo.mergeable === "conflicting" || prInfo.mergeable === "blocked")) {
+ return "card-github-badge--conflicting";
+ }
+
+ if (prInfo.status === "draft" || (prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft))) {
+ return "card-github-badge--draft";
+ }
+
+ if (prInfo.status === "merged") return "card-github-badge--merged";
+ if (prInfo.status === "closed") return "card-github-badge--closed";
+ return "card-github-badge--open";
+}