diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx
index 9dc1c4104a..1485917540 100644
--- a/apps/mobile/src/app/inbox/[...id].tsx
+++ b/apps/mobile/src/app/inbox/[...id].tsx
@@ -57,6 +57,7 @@ import {
formatSignalReportSummaryMarkdown,
inboxStatusLabel,
} from "@/features/inbox/utils";
+import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
import {
computeReportAgeHours,
type InboxReportActionType,
@@ -472,6 +473,15 @@ export default function ReportDetailScreen() {
Updated {timeDisplay}
+ {report.implementation_pr_url ? (
+
+
+
+ ) : null}
{/* Failed warning */}
diff --git a/apps/mobile/src/features/inbox/components/ReportListRow.tsx b/apps/mobile/src/features/inbox/components/ReportListRow.tsx
index 65d70d5e3e..d3cdced3c6 100644
--- a/apps/mobile/src/features/inbox/components/ReportListRow.tsx
+++ b/apps/mobile/src/features/inbox/components/ReportListRow.tsx
@@ -2,6 +2,7 @@ import { Text } from "@components/text";
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
import { memo } from "react";
import { Pressable, View } from "react-native";
+import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
import { useThemeColors } from "@/lib/theme";
import type { SignalReport } from "../types";
@@ -88,6 +89,16 @@ function ReportListRowComponent({ report, onPress }: ReportListRowProps) {
+
+ {report.implementation_pr_url ? (
+
+
+
+ ) : null}
);
}
diff --git a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx
index d28ccbe0fb..b0e2dc4431 100644
--- a/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx
+++ b/apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx
@@ -5,6 +5,7 @@ import { GithubLogo, Lightning } from "phosphor-react-native";
import { useRef } from "react";
import { Animated, PanResponder, Pressable, View } from "react-native";
import { MarkdownText } from "@/features/chat/components/MarkdownText";
+import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
import { useThemeColors } from "@/lib/theme";
import type {
SignalReport,
@@ -290,6 +291,15 @@ function CardContent({
>
)}
+ {report.implementation_pr_url ? (
+
+
+
+ ) : null}
);
diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx
new file mode 100644
index 0000000000..a330030fde
--- /dev/null
+++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.test.tsx
@@ -0,0 +1,111 @@
+import { createElement } from "react";
+import { act, create } from "react-test-renderer";
+import { describe, expect, it, vi } from "vitest";
+import type { PrStatus } from "../hooks/usePrStatus";
+import { PrStatusBadge } from "./PrStatusBadge";
+
+vi.mock("phosphor-react-native", () => ({
+ GitMerge: (props: Record) =>
+ createElement("GitMerge", props),
+ GitPullRequest: (props: Record) =>
+ createElement("GitPullRequest", props),
+}));
+
+vi.mock("@/lib/theme", () => ({
+ useThemeColors: () => ({
+ gray: { 11: "#444444" },
+ status: { success: "#00aa00", error: "#cc0000" },
+ }),
+ toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
+}));
+
+vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() }));
+
+vi.mock("../hooks/usePrStatus", () => ({ usePrStatus: vi.fn() }));
+
+import { usePrStatus } from "../hooks/usePrStatus";
+
+const mockUsePrStatus = vi.mocked(usePrStatus);
+
+function setStatus(data: PrStatus | null | undefined) {
+ mockUsePrStatus.mockReturnValue({ data } as ReturnType);
+}
+
+function render(props: Parameters[0]) {
+ let renderer: ReturnType | null = null;
+ act(() => {
+ renderer = create(createElement(PrStatusBadge, props));
+ });
+ if (!renderer) throw new Error("Renderer not created");
+ return renderer as ReturnType;
+}
+
+function label(renderer: ReturnType): string | undefined {
+ const node = renderer.root.findAll(
+ (n) => typeof n.props?.accessibilityLabel === "string",
+ )[0];
+ return node?.props.accessibilityLabel as string | undefined;
+}
+
+function iconCount(renderer: ReturnType, type: string): number {
+ return renderer.root.findAll((n) => n.type === type).length;
+}
+
+const base: PrStatus = {
+ state: "open",
+ merged: false,
+ draft: false,
+ additions: 0,
+ deletions: 0,
+};
+
+describe("PrStatusBadge", () => {
+ it("renders an open PR badge", () => {
+ setStatus({ ...base, state: "open" });
+ const r = render({ prUrl: "https://github.com/a/b/pull/1" });
+ expect(label(r)).toBe("Open PR");
+ expect(iconCount(r, "GitPullRequest")).toBe(1);
+ });
+
+ it("renders a merged PR badge with the merge icon", () => {
+ setStatus({ ...base, state: "closed", merged: true });
+ const r = render({ prUrl: "https://github.com/a/b/pull/1" });
+ expect(label(r)).toBe("Open merged PR");
+ expect(iconCount(r, "GitMerge")).toBe(1);
+ expect(iconCount(r, "GitPullRequest")).toBe(0);
+ });
+
+ it("renders a closed PR badge", () => {
+ setStatus({ ...base, state: "closed" });
+ const r = render({ prUrl: "https://github.com/a/b/pull/1" });
+ expect(label(r)).toBe("Open closed PR");
+ });
+
+ it("renders a draft PR badge", () => {
+ setStatus({ ...base, state: "open", draft: true });
+ const r = render({ prUrl: "https://github.com/a/b/pull/1" });
+ expect(label(r)).toBe("Open draft PR");
+ });
+
+ it.each([
+ { data: undefined, label: "loading" },
+ { data: null, label: "unresolved (private/404/non-GitHub)" },
+ ])(
+ "renders nothing when hideWhenUnresolved is set and status is $label",
+ ({ data }) => {
+ setStatus(data);
+ const r = render({
+ prUrl: "https://github.com/a/b/pull/1",
+ hideWhenUnresolved: true,
+ });
+ expect(r.toJSON()).toBeNull();
+ },
+ );
+
+ it("still renders a neutral badge for an unresolved PR by default", () => {
+ setStatus(null);
+ const r = render({ prUrl: "https://github.com/a/b/pull/1" });
+ expect(r.toJSON()).not.toBeNull();
+ expect(label(r)).toBe("Open PR");
+ });
+});
diff --git a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx
index f58f6376bb..646b491866 100644
--- a/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx
+++ b/apps/mobile/src/features/tasks/components/PrStatusBadge.tsx
@@ -6,6 +6,11 @@ import { usePrStatus } from "../hooks/usePrStatus";
interface PrStatusBadgeProps {
prUrl: string;
+ // Render nothing until the PR state resolves, and only for a canonical
+ // GitHub PR URL. Inbox surfaces use this so an always-on neutral icon never
+ // implies a status we couldn't confirm (private repo, 404, unparseable URL).
+ hideWhenUnresolved?: boolean;
+ size?: "sm" | "md";
}
// Mirrors the desktop "merged" PR color (Radix purple-9 family). Theme tokens
@@ -13,10 +18,16 @@ interface PrStatusBadgeProps {
// fixed value works in both light and dark.
const MERGED_COLOR = "#8e4ec6";
-export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) {
+export function PrStatusBadge({
+ prUrl,
+ hideWhenUnresolved = false,
+ size = "md",
+}: PrStatusBadgeProps) {
const themeColors = useThemeColors();
const { data: status } = usePrStatus(prUrl);
+ if (hideWhenUnresolved && !status) return null;
+
const handlePress = () => {
openExternalUrl(prUrl);
};
@@ -40,11 +51,14 @@ export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) {
label = "Open PR";
}
+ const box = size === "sm" ? "h-7 w-7" : "h-9 w-9";
+ const iconSize = size === "sm" ? 16 : 20;
+
return (
-
+
);
}